feat: 新增 AICODE-06 魔幻俄罗斯方块第6-7课教案及 demo

- AICODE06-06 魔幻俄罗斯方块(上):工程师思维启蒙,Level 0 需求文档 + 压力测试 + 结果溯源
- AICODE06-07 魔幻俄罗斯方块(下):增量需求文档 + 魔改升级 + 成果路演
- demo/demo-level0.html:完整基础俄罗斯方块(含虚影、暂停、升级)
- demo/demo-level1-bomb.html:炸弹方块示例(含粒子爆炸特效)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rocky
2026-04-09 14:37:58 +02:00
parent 7eac00a35c
commit 3384472d0d
4 changed files with 1788 additions and 0 deletions

View File

@@ -0,0 +1,396 @@
---
课时: 6
主题: 魔幻俄罗斯方块(上)— 工程师思维启蒙
核心能力: [提问力, 拆解力]
核心工具: [Kimi 2.5]
时长: 90分钟
透明化层级: 过程层
适用路线: AICODE-06
---
### 1. 课程目标
**知识目标:**
- 理解「需求文档」的作用:把脑子里的想法变成 AI 能准确执行的指令
- 理解「需求质量 = 输出质量」AI 做出来的结果不符合预期,根本原因是需求没说清楚
- 理解需求是迭代的过程,不是一次写完就能用
**能力目标:**
- 能通过「侦探模式」分析一个游戏的规则,并用文字描述出来(拆解力)
- 能用 AI 对话主动「压力测试」自己的需求文档,发现描述漏洞(提问力)
- 能在看到生成结果后,定位是哪条需求没说清楚,修改并重新生成(共创力)
**情感目标:**
- 建立「先想清楚再动手」的工程师直觉,体验「需求清晰 → 结果可预期」的成就感
- 对「自己也能做出一个游戏」产生真实的兴奋感
- 建立「结果不对 = 需求没说清楚」而不是「AI 不行」或「我不行」的归因习惯
---
### 2. 核心概念与误概念预设
**核心概念认知层级:**
| 概念 | 学生类比 | 认知层级 |
|------|---------|---------|
| 需求文档 | 装修前给师傅的设计图——口头说「弄好看点」和给一张详细图纸,结果完全不同 | 理解层 |
| 需求压力测试 | 考试前找同学互出题——让别人挑毛病比自己检查更有效 | 应用层 |
| 结果溯源 | 菜做咸了,往回找:是盐放多了,还是酱油放多了?找到根源才能改 | 应用层 |
| 需求迭代 | 第一版草稿 → 发现问题 → 修改 → 第二版,就像改作文 | 理解层 |
**典型误概念表:**
| 编号 | 误概念 | 正确认知 | 激发策略 |
|------|--------|---------|---------|
| M1 | 直接告诉 AI「帮我做一个俄罗斯方块」就够了 | 不写需求文档AI 会自己猜所有细节,结果可能跟你想的完全不同 | 课堂实验:先不写需求文档直接生成,再写了需求文档生成,对比两个结果的差距 |
| M2 | 需求文档写完就是写好了,直接提交 | 写完只是第一步,要经过压力测试才能发现漏洞 | 用「压力测试」提示词,让 AI 提出学生自己没想到的问题 |
| M3 | AI 做出来的结果不对是 AI 的问题 | 结果不对说明需求里有没说清楚的地方 | 「你的需求文档里有没有写这一条?」——让学生自己发现根本没写 |
| M4 | 需求文档要写得很长很详细 | 精准 > 冗长。需求要「可测试」:能说出什么情况下算做对了 | 「你这条需求,我怎么知道 AI 做没做对?」 |
| M5 | 先动手做,遇到问题再想 | 先想清楚的时间,会节省后面改来改去的时间 | 对比两种路径的时间消耗:「先做 → 改 → 改 → 改」vs「先想清楚 → 做 → 小改」 |
---
### 3. 教学准备
**工具与环境:**
- 每台电脑已登录 Kimi 2.5,网络正常
- 每台电脑可以正常运行 HTML 文件(浏览器打开)
- 投影可切换至任意学生屏幕
**教学资源:**
- 教师准备:一个真实可玩的俄罗斯方块网页版(课堂演示用,备用链接)
- 教师准备「压力测试」提示词见第5节投屏展示
- 教师准备一份「没写需求文档」直接生成的俄罗斯方块演示提前生成好展示各种「AI 自作主张」的细节)
- 教师准备Level 0 需求文档模板见第5节发给学生
- 学生资源:无需提前准备
**教师备课体验任务:**
> 备课前,教师必须亲自完成以下操作:
>
> 1. 用一句话「帮我做一个俄罗斯方块」提交给 Kimi保存生成结果记录哪些细节是 AI 自己决定的
> 2. 写一份 Level 0 需求文档,走完「压力测试 → 完善 → 提交生成 → 验收 → 发现问题 → 溯源 → 修改 → 第二版」全流程,记录卡在哪里
> 3. 准备好 2-3 个「需求没说清楚」的典型例子(比如「消行规则写了但没写消多行的得分」)
---
### 4. 教学流程
**第一幕:联系 (Connect) — 10分钟** 🔗
**【环节】侦探模式导入 (10分钟)**
**师:** 今天我们要做一个游戏。但在做之前,先玩两分钟。
【投屏展示俄罗斯方块,或让学生在自己电脑上打开】
**师:** 你们的任务不是拿高分,而是当侦探——把这个游戏所有的规则找出来,写在纸上。你能找到多少条就写多少条。两分钟,开始。
【学生玩游戏同时记录规则,教师走动观察学生找到了哪些规则】
**师:** 好,停。谁来说一条规则?
**生:** 方块会往下落。
**师:** 好,这是一条。还有呢?
**生:** 可以左右移动,可以旋转。满一行就消掉。
**师:** 不错。这些都是游戏规则。现在我问一个问题——如果我让 AI 帮我做这个游戏,我直接说「帮我做一个俄罗斯方块」,你们觉得 AI 能做出来吗?
**生:** 能!/ 应该能?
**师:** 我们来试试。
【投屏展示教师提前生成的「一句话版」俄罗斯方块】
**师:** 做出来了。但你们来找找看,这个游戏里,有没有什么地方跟你心里想的「俄罗斯方块」不一样?
【诊断点:学生是否能发现 AI 自作主张的细节——比如得分规则、速度、旋转方向等】【识别层】
**【分支A】若学生发现了不同的地方**
**师:** 你发现了。为什么 AI 做出来跟你想的不一样?
**生:** 因为我没有告诉它……
**师:**你没说AI 就自己猜了一个。今天我们要学的,就是怎么把「脑子里的游戏」变成 AI 能准确执行的指令。这个东西叫需求文档。
**【分支B】若学生觉得「都一样没问题」**
**师:** 那我问你——这个游戏消一行得多少分?消四行一次呢?
**生:** (去看)……好像是 100 分?
**师:** 你想要的是这个分数吗?
**生:** 我没想过……
**师:** 你没想过AI 就帮你决定了。如果你想要自己的规则,就需要告诉 AI。这就是需求文档的意义。
---
**第二幕:建构 (Construct) — 65分钟** 🛠️
**【分段一:写 Level 0 需求文档】(20分钟)**
**预设误概念:**
- 误概念 M1「帮我做俄罗斯方块」就够了不需要写文档
- 误概念 M4需求文档要写得很长很全面
**讲解与演示 (Teach & Demo): (5分钟)**
**师:** 现在我们要写一份需求文档。我给你们一个模板,里面有 5 个必须填的部分。
【投屏展示 Level 0 需求文档模板见第5节】
**师:** 这 5 个部分,每一个都是 AI 「必须知道才能做对」的信息。我们来看第一条——游戏区域:宽多少列、高多少行?
**生:** 10×20
**师:** 这是默认值,但你可以改。重点是:你必须明确告诉 AI否则它自己决定。
**师:** 有一个原则:每条需求,你都要能说出「什么样的结果算做对了」。比如「消行规则:横向填满一整行就消除」——这条我怎么测试?
**生:** 把一行填满,看它有没有消掉。
**师:** 对,这条可以被测试。好的需求就是这样——写完之后,你知道怎么验收。
【理解层:建立「需求可测试性」的直觉】
**学生实践 (Practice): (13分钟)**
学生独立填写 Level 0 需求文档模板,完成 5 个必填部分。
> 教师走动观察重点:
> - 是否有学生在「移动规则」里只写了「可以左右移动」,但没写「碰到边界怎么处理」?
> - 是否有学生在「消行规则」里写了消行但没写「消多行的得分是否不同」?
> - 这些漏洞不要直接告诉学生,留给下一步「压力测试」去发现
**进度同步 (Checkpoint): (2分钟)**
**师:** 5个部分都填完的举手。
**师:**你觉得你的需求文档AI 能根据它做出你想要的游戏吗?
**生:** 应该可以?
**师:** 我们来测试一下你写的文档够不够清楚。
---
**【分段二AI 压力测试 → 完善需求文档】(15分钟)**
**预设误概念:**
- 误概念 M2需求文档写完就可以直接提交了
- 误概念 M3AI 生成不对是 AI 的问题
**讲解与演示 (Teach & Demo): (3分钟)**
**师:** 接下来用一个技巧——让 AI 扮演挑剔的工程师,来「审问」你的需求文档。
【投屏展示「压力测试」提示词见第5节】
**师:** 注意这里:不是让 AI 帮你补全需求,是让 AI 问你问题。这两个完全不同——一个是 AI 替你决定,一个是 AI 帮你发现你自己没想到的地方。
**师:** 我来演示一次。
【教师用自己的需求文档执行压力测试,展示 AI 提出的问题列表】
**师:** AI 问了我哪些问题?有哪些是你们的需求文档里也没有答的?
**学生实践 (Practice): (10分钟)**
1. 学生把自己的需求文档粘贴进「压力测试」提示词,提交 Kimi
2. 把 AI 提出的问题复制到文档里
3. 逐条回答——每个问题写上自己的答案,补充进需求文档
> 教师走动观察:学生是否对某个问题感到惊讶——「这个我真的没想过」是好的信号
**进度同步 (Checkpoint): (2分钟)**
**师:** AI 问了你最意外的是哪个问题?
**生:** 分享1-2条
**师:** 如果不补上这条AI 会自己猜一个答案。那个答案不一定是你想要的。
【识别层建立「每个没说清楚的地方AI 都会自己决定」的意识】
---
**【分段三:提交生成 → 验收 → 结果溯源】(20分钟)**
**预设误概念:**
- 误概念 M3生成完了就算完成了不需要验收
- 误概念 M3结果不对直接改代码
**讲解与演示 (Teach & Demo): (3分钟)**
**师:** 需求文档完善好了,现在提交给 Kimi 生成。但是——生成完不等于完成。生成完之后要做一件事:按需求文档逐条验收。
**师:** 验收方法:你需求文档里写了什么,就测什么。比如你写了「满一行消除」,就在游戏里把一行填满,看它消没消。
**师:** 遇到「结果跟预期不一样」时,**先不要让 AI 改代码**。先做一个动作:找回需求文档,找到是哪一句话没说清楚。这叫结果溯源。
**学生实践 (Practice): (14分钟)**
1. 把完善后的需求文档提交 Kimi 生成游戏
2. 运行游戏,对照需求文档逐条测试
3. 遇到「结果跟预期不一样」时:
- 回到需求文档
- 找到是哪条需求没说清楚,或者根本没写
- 标注「这里有问题原因___」
- 修改需求文档,重新提交生成第二版
> 教师走动观察重点:
> - 学生测试完一条直接跳过 → 走过去问「这条算做对了吗?你的需求是怎么写的?」
> - 学生说「不对」但直接让 AI 改代码 → 叫停,引导回到需求文档:「先找找是哪条需求没说清楚」
**进度同步 (Checkpoint): (3分钟)**
**师:** 谁的第二版比第一版有改善?具体改了什么,改完效果怎样?
【诊断点:学生是否能量化进步——不是「感觉好多了」,而是「之前 XXX 不对,改了需求之后 XXX 对了」】【应用层】
**【分支A】若学生能说出具体改善**
**师:** 这个进步是因为你把需求文档的 XXX 改得更具体了。记住这个感觉——这就是需求驱动输出。
**【分支B】若学生说「改了但没变化」**
**师:** 说明改的地方不是真正的根源。我们再来找——测试结果里还有哪里跟预期不一样?
【帮学生重新溯源,找到真正的问题所在】
---
**【分段四:自由探索 + 下节课铺垫】(10分钟)**
**学生实践 (Practice): (7分钟)**
游戏基本跑起来了之后,学生自由探索:
- 改一下游戏配色,让它更好看
- 或者调整一下速度和得分规则
**师:** 你现在手上有一个可以玩的俄罗斯方块了。下节课,你们可以给它加一个你自己想要的功能。现在开始想——你想给你的游戏加什么?
【诊断点:观察学生自然产生的功能需求,作为下节课教学素材】
学生说出 1-2 个想加的功能(不要求写清楚,只是发散):
- 「我想加个炸弹,可以炸掉一行」
- 「我想让方块会变色」
- 「我想加个 Boss」
**进度同步 (Checkpoint): (3分钟)**
**师:** 说说你想加什么,一句话就行。
(每人说一个,教师快速记录在白板/投屏上)
**师:** 下节课,你们每个人选一个功能,用今天学到的方法——写需求文档、压力测试、生成、验收——把这个功能加进去。
---
**第三幕:反思 (Contemplate) — 10分钟** 🤔
**【环节】成果展示 (6分钟)**
**师:** 谁来展示一下你今天做的游戏?
(邀请 2 名学生展示,展示重点不只是游戏本身,还要说:)
- 你的需求文档里加了什么特别的设计?
- AI 压力测试问了你什么最意外的问题?
- 第一版和第二版有什么不同?
**【环节】互评 (4分钟)**
**师:** 看完 XXX 同学的游戏,你们能不能从他的需求文档里找出一个还没说清楚的地方?
【诊断点:学生是否建立了「挑剔工程师」的眼光】
**师:** 今天最难描述清楚的规则是什么?你最后是怎么写清楚的?
---
**第四幕:延续 (Continue) — 5分钟** 🚀
**【环节】抽象总结 (3分钟)**
**师:** 今天我们做了什么?
**生:** 做了一个俄罗斯方块。
**师:** 对,但更重要的是用什么方法做的?
**生:** 写了需求文档,然后让 AI 做……
**师:** 对。我们今天学的这个方法,有一个名字——工程师思维。工程师做任何事情之前,都先把「要做什么」说清楚。说清楚了,才开始动手。
**师:** 今天这个能力,不只是做游戏用——以后让 AI 做任何事,需求越清晰,结果越接近你想要的。
**【环节】下节预告 + 5分钟挑战 (2分钟)**
**师:** 下节课,每人选一个你想加的功能,用同样的方法——写需求文档、压力测试、生成、验收——把它加进你的俄罗斯方块里。你的游戏会跟任何人的都不一样。
**师:** 本周5分钟挑战想好你下节课要加的功能是什么在脑子里想一想这个功能如果要写需求文档最难描述清楚的是哪一条不用写想一想就行下节课说给我听。
---
### 5. AI 助教使用指南
**Level 0 需求文档模板(发给学生填写):**
```
# 我的俄罗斯方块 · Level 0 需求文档
## 游戏区域
- 宽___列默认10×___行默认20
## 方块移动规则
- 左移按___键
- 右移按___键
- 旋转按___键
- 快速下落按___键
- 碰到左右边界___停止/不能再往那边移)
- 碰到底部或其他方块___固定在原地
## 消行规则
- 触发条件___横向填满整行
- 消行后上方积木___全部下移一行
- 消1行得___分消2行得___分消4行一次得___分
## 关卡与速度
- 初始速度每___毫秒下落一格默认800
- 每消___行升一级
- 每升一级速度加快___毫秒
## 游戏结束条件
- 什么时候结束___积木堆到顶部
- 结束后显示___游戏结束画面 + 最终得分)
```
**「压力测试」提示词:**
```
我写了一份俄罗斯方块的需求文档,内容如下:
[粘贴你的需求文档]
请你扮演一个非常挑剔的工程师,读完这份文档后,
找出所有你觉得「没有说清楚」「可以有多种理解」「遇到特殊情况不知道怎么处理」的地方,
用问题的形式列出来(每条一行,以问号结尾)。
不要帮我回答这些问题,不要帮我修改文档,只负责提问。
```
**「结果溯源」引导提示词(发现结果不对时使用):**
```
我的游戏运行结果和我的预期不一样。
具体表现:[描述不对的现象]
我预期应该是:[描述你想要的结果]
我的需求文档里关于这个功能写的是:[粘贴相关需求]
请帮我分析:是需求描述不够清楚,还是需求里根本没写这条规则?
不要帮我修改代码,只帮我找出需求文档里的问题。
```
**「提交生成」提示词(正式版):**
```
请根据以下需求文档,用单个 HTML 文件(内联 CSS 和 JS做一个俄罗斯方块游戏。
要求:严格按照需求文档实现,不要添加文档里没有提到的功能。
需求文档:
[粘贴你的完整需求文档]
```
---
### 6. 教师指南
**本课技术备注:**
单文件 HTML 策略:所有代码写在一个 `index.html` 文件里CSS 和 JS 全部内联。学生双击就能运行不需要任何服务器或安装。Kimi 2.5 生成单文件俄罗斯方块稳定性高,是这门课的最佳选择。
俄罗斯方块核心数据结构(教师理解用,不讲给学生):游戏核心是一个二维数组代表游戏区域,每个格子存 0或颜色值有方块。方块用形状矩阵 + 当前位置表示。学生不需要理解这些,只需要知道「我的需求文档决定了游戏的行为」。
**常见问题 FAQ**
| 问题 | 应对 |
|------|------|
| 「Kimi 生成的代码打开没有反应」 | 检查文件是否保存为 .html 格式;用浏览器(不是记事本)打开 |
| 「方块可以移动出边界」 | 需求文档里「碰到边界怎么处理」这条没写清楚,引导学生溯源并补充 |
| 「消行了但积木没有下移」 | 需求文档里消行后的处理没说清楚,引导溯源 |
| 「游戏结束了但还能继续操作」 | 需求文档里游戏结束条件对应的处理没写,引导补充「结束后禁止操作」 |
| 「我想直接改代码」 | 「你能看懂这段代码是做什么的吗?如果不能,改了可能引发新的问题。先改需求文档,让 AI 重新生成更安全。」 |
**课堂风险预案:**
- 如果 Kimi 一次生成就完全符合预期:恭喜学生,但仍要做压力测试——「这次 AI 猜对了,你能保证下次加新功能时也能猜对吗?」需求文档的价值在于每次都可预期,不只是修复当次问题。
- 如果学生进度差异很大:进度快的学生开始探索「想加什么功能」并尝试写第一版 Level 1 需求文档;进度慢的学生只要完成「生成 + 发现一个问题 + 溯源到需求」这个最小闭环即可。
---
### 7. 5分钟日常AI挑战
**本周挑战:** 想好下节课要加的功能
**挑战说明:** 不用动手,只是想一想:你的俄罗斯方块下节课要加什么功能?这个功能最难描述清楚的是哪一条规则?在脑子里想好,下节课说给老师和同学听。
**下节课分享:** 课前每人说出自己想加的功能,以及预感最难写清楚的那条需求
---
### 8. 拓展任务
**拓展一(推荐):** 给你的游戏加一个「下一个方块预览」功能——把这个功能写成需求文档,测试一下 AI 能不能做出来
**拓展二(挑战):** 给你的游戏加一个「最高分记录」功能——玩完之后会记住历史最高分,下次打开还在

View File

@@ -0,0 +1,356 @@
---
课时: 7
主题: 魔幻俄罗斯方块(下)— 魔改升级 + 成果路演
核心能力: [拆解力, 共创力, 表达力]
核心工具: [Kimi 2.5]
时长: 90分钟
透明化层级: 过程层
适用路线: AICODE-06
---
### 1. 课程目标
**知识目标:**
- 理解「功能扩展」的本质:在已有基础上,通过新一轮需求文档 + 生成 + 验收来增加功能
- 理解「需求冲突」的概念:新功能的规则可能跟已有功能产生交互,需要提前想清楚
- 理解路演不只是展示结果,而是展示「设计决策」——你为什么这样设计
**能力目标:**
- 能独立完成「想法 → 需求文档 → 压力测试 → 生成 → 验收 → 迭代」完整流程(共创力)
- 能在验收时主动测试功能之间的交互,发现潜在冲突(拆解力)
- 能用 3 分钟路演清楚说明:加了什么功能、需求文档改了几版、遇到什么问题怎么解决的(表达力)
**情感目标:**
- 体验「每个人的游戏都不一样」带来的成就感和个性化自豪感
- 建立「我能用需求文档控制 AI 做出我想要的东西」的自信
- 感受从「做一个大家都一样的游戏」到「做一个只属于我的游戏」的跨越
---
### 2. 核心概念与误概念预设
**核心概念认知层级:**
| 概念 | 学生类比 | 认知层级 |
|------|---------|---------|
| 功能扩展 | 在基础款手机上加功能——不是重新做一台手机,而是在已有基础上增加 | 理解层 |
| 需求冲突 | 新加了一条规则跟原来的规则打架了——就像「所有人说话不准超过10秒」和「发言人可以不限时间」同时存在 | 应用层 |
| 增量需求文档 | 只写「新加的部分」以及「新部分跟原有部分的交互规则」,不需要把全部需求重写一遍 | 理解层 |
| 路演 = 决策展示 | 路演不是说「我做了什么」,而是说「我为什么这样设计」——设计背后的思考才是最有价值的 | 迁移层 |
**典型误概念表:**
| 编号 | 误概念 | 正确认知 | 激发策略 |
|------|--------|---------|---------|
| M1 | 加新功能要把整个游戏重新做一遍 | 在已有代码基础上增加功能,只需要写「新功能的需求文档」 | 演示「增量需求文档」的写法,只写新加的部分 |
| M2 | 新功能加进去就算完成了,不用测试已有功能 | 新功能可能跟已有功能冲突,必须测试两者的交互 | 举例:加了「炸弹」,炸弹爆炸后会不会触发消行?触发的话是几分? |
| M3 | 需求文档越来越长越好,把所有想法都写进去 | 本次迭代只写「这次新加的功能」,保持文档聚焦 | 「这次 AI 只需要知道新加什么,不需要把整个游戏重新理解一遍」 |
| M4 | 路演只要展示游戏好不好玩就行 | 路演的核心是展示你的思考:你加了什么、为什么加、需求文档改了几版、遇到什么问题 | 对比两种路演:一个只展示功能,一个讲设计决策,让学生投票哪个更精彩 |
| M5 | 没写清楚的需求可以让 AI 自己发挥 | 「让 AI 自己发挥」的部分你就失去了控制,结果可能跟你想的完全不同 | 「你让 AI 自己发挥了哪里?那里 AI 做出来的跟你想的一样吗?」 |
---
### 3. 教学准备
**工具与环境:**
- 每台电脑已登录 Kimi 2.5,网络正常
- 学生上节课第6课完成的俄罗斯方块 HTML 文件保存在桌面
- 投影可切换至任意学生屏幕
- 准备路演计时器3分钟倒计时
**教学资源:**
- 教师准备「增量需求文档」提示词见第5节
- 教师准备几个功能想法的参考清单防止学生没有灵感见第5节
- 教师准备路演结构引导卡见第5节路演前发给学生
- 学生资源第6课完成的俄罗斯方块 HTML 文件
**教师备课体验任务:**
> 备课前,教师必须亲自完成以下操作:
>
> 1. 选2-3个功能比如炸弹方块、速度加成、颜色主题各写一份增量需求文档实际提交 Kimi 测试生成质量
> 2. 故意在一个功能的需求文档里漏写「与消行的交互规则」,观察 AI 会怎么处理
> 3. 练习一遍 3 分钟路演,讲「设计决策」而不只是展示游戏
---
### 4. 教学流程
**第一幕:联系 (Connect) — 10分钟** 🔗
**【环节】上节课回顾 + 功能发布 (10分钟)**
**师:** 上节课结束前,我让你们想一个下节课要加的功能。现在每个人说出来——你想给你的俄罗斯方块加什么?
【每人快速说一个,教师写在白板/投屏上】
**师:** 我们来看一下大家想加的功能。
(读出清单)有没有一模一样的?
**【分支A】若有学生选了相同的功能**
**师:** 你们选了同样的功能,但你们的需求文档可能完全不一样——因为每个人对这个功能的设计可能不同。比如同样是「炸弹方块」,炸弹爆炸多大范围?爆炸后会不会消行?这些都是你自己决定的。
**【分支B】若所有人都选了不同的功能**
**师:** 大家选的都不一样——这节课结束,每个人的游戏都会跟别人的不一样。这就是今天最有意思的地方。
**师:** 来简单说一下——你想加的功能,最难描述清楚的是哪一条规则?
**生:** (各自说出预感最难写的部分)
**师:** 很好,记住这个预感。等会写需求文档的时候,那条规则要特别仔细写。
【诊断点:学生上节课课后是否真的想了,还是现在才开始想】【识别层】
---
**第二幕:建构 (Construct) — 65分钟** 🛠️
**【分段一:写增量需求文档 + 压力测试】(20分钟)**
**预设误概念:**
- 误概念 M1加新功能要把整个游戏重做
- 误概念 M3需求文档要把所有东西都写进去
**讲解与演示 (Teach & Demo): (5分钟)**
**师:** 今天写的需求文档,跟上节课不一样。上节课写的是「整个游戏的规则」,今天只写「新加的功能」。这叫增量需求文档。
【投屏展示增量需求文档提示词见第5节】
**师:** 有一个特别重要的部分——「与原有功能的交互规则」。我举个例子:你加了一个炸弹方块,炸弹爆炸了,爆炸范围里刚好有一整行被清空了——这算消行吗?算消行的话,得多少分?
**生:** ……这个我没想过。
**师:** 对,这就是功能交互。新功能和原有功能之间,总会有一些「撞在一起」的情况,需要提前想清楚。
【理解层:建立「功能之间有交互」的设计意识】
**师:** 我来演示一次——用「炸弹方块」功能写一份增量需求文档,然后做一次压力测试。
【教师演示:写需求 → 压力测试 → AI 提问 → 补漏洞,重点展示「交互规则」这部分】
**学生实践 (Practice): (13分钟)**
1. 学生写自己的功能的增量需求文档(重点写清楚:功能规则 + 与消行/得分的交互规则)
2. 执行「压力测试」提示词
3. 针对 AI 提出的问题,补充完善需求文档
> 教师走动观察重点:
> - 学生是否写了「交互规则」这一部分?
> - 对于「AI 问了什么」感到最意外的问题是什么?
**进度同步 (Checkpoint): (2分钟)**
**师:** AI 压力测试问了你最意外的问题是什么?
**生:** 分享1-2条重点是「交互规则」相关的问题
**师:** 这类问题,如果不写清楚,加进去的功能会跟原来的规则「打架」,产生奇怪的结果。
---
**【分段二:提交生成 → 测试交互 → 溯源迭代】(25分钟)**
**预设误概念:**
- 误概念 M2新功能加进去之后只测新功能就行
- 误概念 M5没写清楚的部分让 AI 自己决定
**讲解与演示 (Teach & Demo): (3分钟)**
**师:** 把需求文档提交 Kimi有一个特别的要求——告诉 AI 「在已有代码基础上增加功能」,而不是「重新做一个游戏」。
【投屏展示「增量生成」提示词见第5节】
**师:** 生成之后,验收的时候要测两件事:
- 第一:新功能有没有按需求做出来
- 第二:原来的功能还正常吗(消行、得分、游戏结束,都要测)
**师:** 遇到不对的地方,还是同样的动作——溯源到需求文档,找到是哪条没说清楚,改需求,重新生成。
**学生实践 (Practice): (19分钟)**
1. 把需求文档提交 Kimi基于已有代码增加新功能
2. 验收:新功能测试 + 原有功能回归测试
3. 记录「结果跟预期不一样」的地方
4. 溯源到需求文档 → 修改 → 重新生成
5. 重复迭代,直到功能符合预期
> 教师走动观察重点:
> - 新功能和消行之间的交互是否符合需求文档的设计?
> - 是否有学生因为新功能导致原有功能出问题(回归 bug
> - 学生是否在需求文档里找到了回归 bug 的根源?
**进度同步 (Checkpoint): (3分钟)**
**师:** 你加的新功能,有没有让原来的某个功能出问题?怎么溯源到需求文档的?
【诊断点:学生是否理解「新功能可能影响旧功能」,并能溯源到需求文档里的交互规则】【应用层】
**【分支A】若学生发现了回归 bug 并成功溯源:**
**师:** 非常好!你刚才发现的问题,在需求文档里是哪条交互规则没有写清楚?
**【分支B】若学生说「没有出问题一切正常」**
**师:** 那我们来测一个边界情况——你的新功能触发的同时,刚好消了一行,得分是怎么算的?
【主动暴露交互场景,测试是否真的没问题】
---
**【分段三:路演准备】(10分钟)**
**预设误概念:**
- 误概念 M4路演只要展示游戏好不好玩就行
**讲解与演示 (Teach & Demo): (3分钟)**
**师:** 接下来每个人做一个 3 分钟路演。但我们的路演跟一般的展示不一样——不是「看,我做了个游戏」,而是「我为什么这样设计」。
【发放路演结构引导卡见第5节】
**师:** 路演要说三件事:
1. 我加了什么功能,这个功能是什么效果
2. 我的需求文档改了几版,每次改了什么
3. 遇到的最难的地方是什么,怎么解决的
**师:** 注意第二和第三点——这才是最有价值的部分。任何人都能展示一个游戏,但你经历了哪些思考和决策,是只有你有的。
**学生实践 (Practice): (7分钟)**
学生准备路演,按三点结构整理思路(不需要写逐字稿,想清楚就行)。
教师走动帮助学生回忆「需求文档改了几版」「哪里卡住了怎么解决的」。
---
**第三幕:反思 (Contemplate) — 10分钟** 🤔
**【环节】成果路演 (8分钟)**
每位学生 3 分钟路演按人数调整6人小班每人约 1.5 分钟,重点展示设计决策)。
**师:** (每人路演后)你的需求文档改了几版?最关键的那次修改是什么?
**【环节】互评 (2分钟)**
**师:** 刚才大家展示的游戏里,哪个功能的需求文档你觉得写得最清楚?
**师:** 哪个功能的设计,是你之前没想到可以这样做的?
【诊断点:学生是否能评价「需求清晰度」,而不只是「游戏好不好玩」】
---
**第四幕:延续 (Continue) — 5分钟** 🚀
**【环节】抽象总结 (3分钟)**
**师:** 两节课,你们从零做了一个俄罗斯方块,还加了自己设计的功能。我们用的核心方法是什么?
**生:** 写需求文档,让 AI 做,然后验收,有问题就改需求文档……
**师:** 对。这个流程有个名字:需求 → 设计 → 实现 → 验收 → 迭代。这是真实的工程师每天都在做的事情。
**师:** 你们今天做的,跟真实的产品开发,步骤上是完全一样的。
**师:** 最后一个问题:如果今天你的功能需求文档第一版就做对了,是因为你运气好,还是需求写得好?
**生:** 需求写得好?
**师:** 对。运气不可靠,清晰的需求才可靠。这是今天最重要的一句话。
**【环节】下节预告 (2分钟)**
**师:** 接下来的课程,你们会进入更大的项目。今天学到的方法——需求文档、压力测试、结果溯源——会一直用到。你们已经会了,后面只是把项目变得更复杂。
---
### 5. AI 助教使用指南
**增量需求文档模板(学生填写):**
```
# 新功能需求文档:[功能名称]
## 功能描述
[用一两句话说清楚这个功能是什么]
## 触发条件
[什么情况下这个功能会出现/触发?]
## 功能规则
[这个功能具体怎么运作?每条规则尽量具体]
## 与原有功能的交互规则
- 与消行的交互:[这个功能触发时,如果同时消了行,怎么处理?]
- 与得分的交互:[这个功能会影响得分吗?怎么影响?]
- 与游戏结束的交互:[游戏结束时,这个功能有没有特殊处理?]
## 验收标准
[我怎么测试这个功能做对了列出2-3条可以测试的情况]
```
**「增量生成」提示词:**
```
我已经有一个俄罗斯方块游戏(代码在下面)。
现在我需要在这个基础上增加一个新功能,需求如下:
[粘贴你的增量需求文档]
要求:
1. 在已有代码基础上增加这个功能,不要改变原有功能的行为
2. 严格按照需求文档实现,不要添加文档里没有提到的内容
3. 输出完整的 HTML 文件(内联 CSS 和 JS
已有代码:
[粘贴你的游戏 HTML 代码]
```
**功能灵感参考清单(学生没有想法时参考):**
| 功能 | 一句话说明 |
|------|---------|
| 炸弹方块 | 特殊方块,落地后炸掉周围一圈 |
| 速度加成方块 | 碰到就让下落速度加快 5 秒 |
| 冻结方块 | 碰到就让当前方块暂停 3 秒不下落 |
| 彩虹模式 | 消行的时候屏幕闪一下彩虹色 |
| 双人模式 | 两个人在同一个游戏区域交替操控 |
| Boss 行 | 每隔 10 行出现一行不能被普通消行消掉的「Boss 行」 |
| 方块预言家 | 显示接下来 3 个方块而不只是 1 个 |
| 重力反转 | 消 4 行一次触发 5 秒重力反转,方块往上飞 |
**路演结构引导卡(课前发给学生):**
```
我的路演3分钟
1. 我加了什么功能30秒
→ 演示给大家看
2. 我的需求文档改了几版1分钟
→ 第一版写完之后AI 问了我什么让我意外的问题?
→ 我改了哪里?改完之后结果有什么变化?
3. 最难的地方1分钟
→ 我遇到的最难描述清楚的规则是什么?
→ 最后是怎么写清楚的?
→ 有没有发现新功能让原来的功能出了问题?怎么解决的?
4. 如果重来一次30秒
→ 需求文档哪里会写得不一样?
```
---
### 6. 教师指南
**本课技术备注:**
增量开发策略:把已有的 HTML 代码粘贴给 Kimi让它在基础上增加功能比重新生成稳定得多。如果 Kimi 倾向于重写整个游戏,在提示词里加「请务必基于我提供的代码修改,不要重新生成整个游戏」。
功能复杂度控制:炸弹方块、速度加成、冻结方块是低复杂度功能,适合大多数学生。重力反转、双人模式是高复杂度功能,适合进度快的学生挑战。教师可以根据学生能力在发清单时口头引导。
**常见问题 FAQ**
| 问题 | 应对 |
|------|------|
| 「加了新功能之后原来的功能坏了」 | 先检查需求文档里「与原有功能的交互规则」写了什么;如果没写,补上去重新生成 |
| 「Kimi 加功能之后把整个游戏重写了,之前的功能不见了」 | 提示词里强调「在已有代码基础上修改」;或者把原有代码中关键的函数名告诉 Kimi要求保留 |
| 「我想要的功能 AI 做不出来」 | 先检查需求文档是否足够具体;可以要求 AI「只实现这一个功能不要改其他任何部分」确实做不到的记录进 Backlog 留待下次 |
| 「路演不知道说什么」 | 引导卡上的4个问题逐一回答就够了重点提醒「不需要说得很完整说你觉得最有意思的那件事就行」 |
| 「时间不够,功能还没做完」 | 没关系,路演时说「我加了什么功能、需求文档写到第几版、还差什么没完成」也是完整的路演——这本身就是真实工程师的日常 |
**课堂风险预案:**
- 如果多名学生选了同一个功能(比如炸弹方块):利用这个机会对比两个人的需求文档——相同的功能,两份需求文档有什么不同?最终生成的结果有什么不同?这是很好的教学素材。
- 如果某个学生的功能过于复杂(比如联机对战):引导学生做「最小可行版本」——先只实现最核心的规则,其余的加进 Backlog下次再加。
---
### 7. 5分钟日常AI挑战
**本周挑战:** 再加一个功能,或者把这次没做完的功能完成
**挑战说明:** 用同样的方法——写增量需求文档、压力测试、生成、验收——自己在家给游戏再加一个功能(或者完成这节课没完成的部分)。下节课展示。
**下节课分享:** 选 2-3 位同学展示在家加的功能,重点说「需求文档是怎么写的」
---
### 8. 拓展任务
**拓展一(推荐):** 给你的游戏加一个「暂停功能」——按 P 键暂停,再按 P 键继续;暂停时显示一个半透明遮罩
**拓展二(挑战):** 给你的游戏写一份「完整的 Level 0 + Level 1 需求文档合并版」——把上节课的需求文档和今天的增量需求文档合并成一份完整的文档,让任何人读了都能理解整个游戏的所有规则

View File

@@ -0,0 +1,460 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>魔幻俄罗斯方块 · Level 0</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a2e;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-family: Arial, sans-serif;
color: #fff;
}
.game-container {
display: flex;
gap: 20px;
align-items: flex-start;
}
canvas#board {
border: 2px solid #e94560;
box-shadow: 0 0 20px rgba(233, 69, 96, 0.3);
display: block;
}
.side-panel { width: 120px; }
.panel-box {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 8px;
padding: 12px;
margin-bottom: 14px;
}
.panel-box h3 {
font-size: 11px;
color: #e94560;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 6px;
}
.panel-box p {
font-size: 22px;
font-weight: bold;
}
.controls {
font-size: 11px;
color: #888;
line-height: 2;
}
#overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.75);
justify-content: center;
align-items: center;
}
#overlay.show { display: flex; }
.overlay-box {
background: #16213e;
border: 2px solid #e94560;
border-radius: 12px;
padding: 32px 40px;
text-align: center;
}
.overlay-box h2 { color: #e94560; font-size: 24px; margin-bottom: 12px; }
.overlay-box p { color: #aaa; margin-bottom: 20px; }
.overlay-box span { color: #fff; font-size: 28px; font-weight: bold; }
button {
padding: 10px 28px;
background: #e94560;
color: #fff;
border: none;
border-radius: 6px;
font-size: 15px;
cursor: pointer;
margin-top: 16px;
}
button:hover { background: #c73652; }
</style>
</head>
<body>
<div class="game-container">
<canvas id="board" width="300" height="600"></canvas>
<div class="side-panel">
<div class="panel-box">
<h3>下一个</h3>
<canvas id="next-canvas" width="80" height="80"></canvas>
</div>
<div class="panel-box">
<h3>得分</h3>
<p id="score">0</p>
</div>
<div class="panel-box">
<h3>等级</h3>
<p id="level">1</p>
</div>
<div class="panel-box">
<h3>消行</h3>
<p id="lines">0</p>
</div>
<div class="panel-box controls">
<h3>操作</h3>
← → 移动<br>
↑ 旋转<br>
↓ 加速<br>
空格 直落<br>
P 暂停
</div>
</div>
</div>
<div id="overlay">
<div class="overlay-box">
<h2>游戏结束</h2>
<p>最终得分</p>
<span id="final-score">0</span>
<br>
<button onclick="startGame()">再来一次</button>
</div>
</div>
<script>
// =====================
// 配置(需求文档对应的参数)
// =====================
const CONFIG = {
COLS: 10,
ROWS: 20,
CELL: 30,
BASE_SPEED: 800, // 初始下落间隔(毫秒)
SPEED_STEP: 50, // 每级加快多少毫秒
LINES_PER_LEVEL: 10, // 每消多少行升级
// 消1/2/3/4行的基础得分
SCORE_TABLE: [0, 100, 300, 500, 800],
};
// =====================
// 7种标准方块
// =====================
const TETROMINOES = [
{ shape: [[1,1,1,1]], color: '#00f5ff' }, // I
{ shape: [[1,1],[1,1]], color: '#ffd700' }, // O
{ shape: [[0,1,0],[1,1,1]], color: '#bf5fff' }, // T
{ shape: [[0,1,1],[1,1,0]], color: '#39ff14' }, // S
{ shape: [[1,1,0],[0,1,1]], color: '#ff3131' }, // Z
{ shape: [[1,0,0],[1,1,1]], color: '#ff8c00' }, // J
{ shape: [[0,0,1],[1,1,1]], color: '#0080ff' }, // L
];
// =====================
// 游戏状态变量
// =====================
let board, current, next;
let score, level, lines;
let running, paused;
let lastTs, dropAcc, dropInterval;
let rafId;
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const nextCvs = document.getElementById('next-canvas');
const nextCtx = nextCvs.getContext('2d');
// =====================
// 启动 / 重置游戏
// =====================
function startGame() {
document.getElementById('overlay').classList.remove('show');
board = Array.from({ length: CONFIG.ROWS }, () => Array(CONFIG.COLS).fill(0));
score = 0; level = 1; lines = 0;
running = true; paused = false;
dropInterval = CONFIG.BASE_SPEED;
dropAcc = 0; lastTs = 0;
updateHUD();
next = newPiece();
spawnPiece();
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(loop);
}
// =====================
// 生成随机方块对象
// =====================
function newPiece() {
const t = TETROMINOES[Math.floor(Math.random() * TETROMINOES.length)];
return {
shape: t.shape.map(r => [...r]),
color: t.color,
x: Math.floor(CONFIG.COLS / 2) - Math.floor(t.shape[0].length / 2),
y: 0,
};
}
// =====================
// 在顶部生成下一块
// =====================
function spawnPiece() {
current = next;
current.x = Math.floor(CONFIG.COLS / 2) - Math.floor(current.shape[0].length / 2);
current.y = 0;
next = newPiece();
drawNext();
if (hit(current, 0, 0)) endGame();
}
// =====================
// 碰撞检测
// =====================
function hit(piece, dx, dy) {
for (let r = 0; r < piece.shape.length; r++) {
for (let c = 0; c < piece.shape[r].length; c++) {
if (!piece.shape[r][c]) continue;
const nx = piece.x + c + dx;
const ny = piece.y + r + dy;
if (nx < 0 || nx >= CONFIG.COLS) return true;
if (ny >= CONFIG.ROWS) return true;
if (ny >= 0 && board[ny][nx]) return true;
}
}
return false;
}
// =====================
// 顺时针旋转矩阵
// =====================
function rotateCW(shape) {
const R = shape.length, C = shape[0].length;
const out = Array.from({ length: C }, () => Array(R).fill(0));
for (let r = 0; r < R; r++)
for (let c = 0; c < C; c++)
out[c][R - 1 - r] = shape[r][c];
return out;
}
// =====================
// 将当前方块锁定到棋盘
// =====================
function lock() {
for (let r = 0; r < current.shape.length; r++)
for (let c = 0; c < current.shape[r].length; c++)
if (current.shape[r][c] && current.y + r >= 0)
board[current.y + r][current.x + c] = current.color;
clearLines();
spawnPiece();
}
// =====================
// 消行(从下往上扫描)
// =====================
function clearLines() {
let cleared = 0;
for (let r = CONFIG.ROWS - 1; r >= 0; r--) {
if (board[r].every(cell => cell !== 0)) {
board.splice(r, 1);
board.unshift(Array(CONFIG.COLS).fill(0));
cleared++;
r++; // 重新检查移下来的行
}
}
if (cleared === 0) return;
score += CONFIG.SCORE_TABLE[cleared] * level;
lines += cleared;
const newLevel = Math.floor(lines / CONFIG.LINES_PER_LEVEL) + 1;
if (newLevel > level) {
level = newLevel;
dropInterval = Math.max(100, CONFIG.BASE_SPEED - (level - 1) * CONFIG.SPEED_STEP);
}
updateHUD();
}
// =====================
// 主循环
// =====================
function loop(ts) {
rafId = requestAnimationFrame(loop);
if (!running || paused) return;
const dt = ts - lastTs;
lastTs = ts;
dropAcc += dt;
if (dropAcc >= dropInterval) {
if (!hit(current, 0, 1)) current.y++;
else lock();
dropAcc = 0;
}
render();
}
// =====================
// 键盘控制
// =====================
document.addEventListener('keydown', e => {
if (!running) return;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
if (!paused && !hit(current, -1, 0)) current.x--;
break;
case 'ArrowRight':
e.preventDefault();
if (!paused && !hit(current, 1, 0)) current.x++;
break;
case 'ArrowDown':
e.preventDefault();
if (!paused) {
if (!hit(current, 0, 1)) current.y++;
else lock();
dropAcc = 0;
}
break;
case 'ArrowUp':
e.preventDefault();
if (!paused) tryRotate();
break;
case ' ':
e.preventDefault();
if (!paused) hardDrop();
break;
case 'p': case 'P':
paused = !paused;
break;
}
});
function tryRotate() {
const rotated = rotateCW(current.shape);
const orig = current.shape;
current.shape = rotated;
// 踢墙:旋转后碰撞就尝试左右移一格
if (hit(current, 0, 0)) {
if (!hit(current, 1, 0)) current.x++;
else if (!hit(current, -1, 0)) current.x--;
else current.shape = orig;
}
}
function hardDrop() {
while (!hit(current, 0, 1)) current.y++;
lock();
}
// =====================
// 渲染
// =====================
function render() {
const S = CONFIG.CELL;
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 网格线
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
for (let r = 0; r < CONFIG.ROWS; r++)
for (let c = 0; c < CONFIG.COLS; c++)
ctx.strokeRect(c * S, r * S, S, S);
// 棋盘上的固定方块
for (let r = 0; r < CONFIG.ROWS; r++)
for (let c = 0; c < CONFIG.COLS; c++)
if (board[r][c]) drawCell(ctx, c, r, board[r][c], S);
// 落点虚影
const ghost = ghostPiece();
ghost.shape.forEach((row, r) =>
row.forEach((v, c) => {
if (!v) return;
ctx.fillStyle = 'rgba(255,255,255,0.12)';
ctx.fillRect((ghost.x+c)*S+1, (ghost.y+r)*S+1, S-2, S-2);
})
);
// 当前方块
current.shape.forEach((row, r) =>
row.forEach((v, c) => {
if (v) drawCell(ctx, current.x+c, current.y+r, current.color, S);
})
);
// 暂停遮罩
if (paused) {
ctx.fillStyle = 'rgba(0,0,0,0.65)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff';
ctx.font = 'bold 28px Arial';
ctx.textAlign = 'center';
ctx.fillText('暂停', canvas.width/2, canvas.height/2);
ctx.font = '15px Arial';
ctx.fillStyle = '#aaa';
ctx.fillText('按 P 继续', canvas.width/2, canvas.height/2 + 34);
}
}
function drawCell(context, cx, cy, color, S) {
context.fillStyle = color;
context.fillRect(cx*S+1, cy*S+1, S-2, S-2);
context.fillStyle = 'rgba(255,255,255,0.18)';
context.fillRect(cx*S+1, cy*S+1, S-2, 4);
}
function ghostPiece() {
const g = { shape: current.shape, x: current.x, y: current.y };
while (!hit(g, 0, 1)) g.y++;
return g;
}
function drawNext() {
const S = 20;
nextCtx.fillStyle = '#16213e';
nextCtx.fillRect(0, 0, nextCvs.width, nextCvs.height);
const ox = Math.floor((4 - next.shape[0].length) / 2);
const oy = Math.floor((4 - next.shape.length) / 2);
next.shape.forEach((row, r) =>
row.forEach((v, c) => {
if (!v) return;
nextCtx.fillStyle = next.color;
nextCtx.fillRect((ox+c)*S, (oy+r)*S, S-1, S-1);
})
);
}
function updateHUD() {
document.getElementById('score').textContent = score;
document.getElementById('level').textContent = level;
document.getElementById('lines').textContent = lines;
}
function endGame() {
running = false;
document.getElementById('final-score').textContent = score;
document.getElementById('overlay').classList.add('show');
}
// 启动
startGame();
</script>
</body>
</html>

View File

@@ -0,0 +1,576 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>魔幻俄罗斯方块 · Level 1 示例:炸弹方块</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a2e;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-family: Arial, sans-serif;
color: #fff;
}
.game-container { display: flex; gap: 20px; align-items: flex-start; }
canvas#board {
border: 2px solid #e94560;
box-shadow: 0 0 20px rgba(233,69,96,0.3);
display: block;
}
.side-panel { width: 130px; }
.panel-box {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 8px;
padding: 12px;
margin-bottom: 14px;
}
.panel-box h3 {
font-size: 11px;
color: #e94560;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 6px;
}
.panel-box p { font-size: 22px; font-weight: bold; }
.bomb-hint {
font-size: 11px;
color: #ffd700;
line-height: 1.7;
}
.controls { font-size: 11px; color: #888; line-height: 2; }
#overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.75);
justify-content: center;
align-items: center;
}
#overlay.show { display: flex; }
.overlay-box {
background: #16213e;
border: 2px solid #e94560;
border-radius: 12px;
padding: 32px 40px;
text-align: center;
}
.overlay-box h2 { color: #e94560; font-size: 24px; margin-bottom: 12px; }
.overlay-box p { color: #aaa; margin-bottom: 8px; }
.overlay-box span { color: #fff; font-size: 28px; font-weight: bold; }
button {
padding: 10px 28px;
background: #e94560;
color: #fff;
border: none;
border-radius: 6px;
font-size: 15px;
cursor: pointer;
margin-top: 16px;
}
button:hover { background: #c73652; }
/* 爆炸动画层 */
#explosion-canvas {
position: fixed;
pointer-events: none;
top: 0; left: 0;
width: 100%; height: 100%;
}
</style>
</head>
<body>
<canvas id="explosion-canvas"></canvas>
<div class="game-container">
<canvas id="board" width="300" height="600"></canvas>
<div class="side-panel">
<div class="panel-box">
<h3>下一个</h3>
<canvas id="next-canvas" width="80" height="80"></canvas>
</div>
<div class="panel-box">
<h3>得分</h3>
<p id="score">0</p>
</div>
<div class="panel-box">
<h3>等级</h3>
<p id="level">1</p>
</div>
<div class="panel-box">
<h3>消行</h3>
<p id="lines">0</p>
</div>
<div class="panel-box bomb-hint">
<h3>💣 炸弹规则</h3>
出现概率20%<br>
落地爆炸:<br>
炸掉 3×3 范围<br>
爆炸后触发消行<br>
额外奖励 200 分
</div>
<div class="panel-box controls">
<h3>操作</h3>
← → 移动<br>
↑ 旋转<br>
↓ 加速<br>
空格 直落<br>
P 暂停
</div>
</div>
</div>
<div id="overlay">
<div class="overlay-box">
<h2>游戏结束</h2>
<p>最终得分</p>
<span id="final-score">0</span>
<br>
<button onclick="startGame()">再来一次</button>
</div>
</div>
<script>
// =====================
// 配置
// =====================
const CONFIG = {
COLS: 10,
ROWS: 20,
CELL: 30,
BASE_SPEED: 800,
SPEED_STEP: 50,
LINES_PER_LEVEL: 10,
SCORE_TABLE: [0, 100, 300, 500, 800],
// 炸弹方块配置
BOMB_CHANCE: 0.20, // 出现概率 20%
BOMB_RADIUS: 1, // 爆炸半径(炸 3×3 = 半径1
BOMB_BONUS: 200, // 爆炸奖励分
};
// =====================
// 方块定义
// =====================
const TETROMINOES = [
{ shape: [[1,1,1,1]], color: '#00f5ff' },
{ shape: [[1,1],[1,1]], color: '#ffd700' },
{ shape: [[0,1,0],[1,1,1]], color: '#bf5fff' },
{ shape: [[0,1,1],[1,1,0]], color: '#39ff14' },
{ shape: [[1,1,0],[0,1,1]], color: '#ff3131' },
{ shape: [[1,0,0],[1,1,1]], color: '#ff8c00' },
{ shape: [[0,0,1],[1,1,1]], color: '#0080ff' },
];
// 炸弹方块(特殊形状:单格)
const BOMB_PIECE = {
shape: [[1]],
color: '#ff4500',
isBomb: true,
};
// =====================
// 状态
// =====================
let board, current, next;
let score, level, lines;
let running, paused;
let lastTs, dropAcc, dropInterval;
let rafId;
let particles = []; // 爆炸粒子
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const nextCvs = document.getElementById('next-canvas');
const nextCtx = nextCvs.getContext('2d');
const expCvs = document.getElementById('explosion-canvas');
const expCtx = expCvs.getContext('2d');
// 让爆炸画布覆盖全屏
function resizeExpCanvas() {
expCvs.width = window.innerWidth;
expCvs.height = window.innerHeight;
}
resizeExpCanvas();
window.addEventListener('resize', resizeExpCanvas);
// =====================
// 启动游戏
// =====================
function startGame() {
document.getElementById('overlay').classList.remove('show');
board = Array.from({ length: CONFIG.ROWS }, () => Array(CONFIG.COLS).fill(0));
score = 0; level = 1; lines = 0;
running = true; paused = false;
dropInterval = CONFIG.BASE_SPEED;
dropAcc = 0; lastTs = 0;
particles = [];
updateHUD();
next = newPiece();
spawnPiece();
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(loop);
}
// =====================
// 生成方块20% 概率炸弹)
// =====================
function newPiece() {
if (Math.random() < CONFIG.BOMB_CHANCE) {
return { ...BOMB_PIECE, shape: [[1]], x: 0, y: 0 };
}
const t = TETROMINOES[Math.floor(Math.random() * TETROMINOES.length)];
return { shape: t.shape.map(r => [...r]), color: t.color, isBomb: false, x: 0, y: 0 };
}
function spawnPiece() {
current = next;
current.x = Math.floor(CONFIG.COLS / 2) - Math.floor(current.shape[0].length / 2);
current.y = 0;
next = newPiece();
drawNext();
if (hit(current, 0, 0)) endGame();
}
// =====================
// 碰撞检测
// =====================
function hit(piece, dx, dy) {
for (let r = 0; r < piece.shape.length; r++) {
for (let c = 0; c < piece.shape[r].length; c++) {
if (!piece.shape[r][c]) continue;
const nx = piece.x + c + dx;
const ny = piece.y + r + dy;
if (nx < 0 || nx >= CONFIG.COLS) return true;
if (ny >= CONFIG.ROWS) return true;
if (ny >= 0 && board[ny][nx]) return true;
}
}
return false;
}
// =====================
// 旋转
// =====================
function rotateCW(shape) {
const R = shape.length, C = shape[0].length;
const out = Array.from({ length: C }, () => Array(R).fill(0));
for (let r = 0; r < R; r++)
for (let c = 0; c < C; c++)
out[c][R-1-r] = shape[r][c];
return out;
}
function tryRotate() {
if (current.isBomb) return; // 炸弹不需要旋转
const rotated = rotateCW(current.shape);
const orig = current.shape;
current.shape = rotated;
if (hit(current, 0, 0)) {
if (!hit(current, 1, 0)) current.x++;
else if (!hit(current, -1, 0)) current.x--;
else current.shape = orig;
}
}
// =====================
// 锁定方块
// =====================
function lock() {
if (current.isBomb) {
// 炸弹落地:触发爆炸
const bx = current.x;
const by = current.y;
explode(bx, by);
} else {
for (let r = 0; r < current.shape.length; r++)
for (let c = 0; c < current.shape[r].length; c++)
if (current.shape[r][c] && current.y + r >= 0)
board[current.y + r][current.x + c] = current.color;
clearLines();
}
spawnPiece();
}
// =====================
// 炸弹爆炸逻辑
// =====================
function explode(cx, cy) {
// 获取爆炸中心在棋盘上的坐标
const R = CONFIG.BOMB_RADIUS;
// 计算爆炸中心像素坐标(用于粒子特效)
const S = CONFIG.CELL;
// 找到 board 画布在屏幕上的位置
const boardRect = canvas.getBoundingClientRect();
const pixelX = boardRect.left + cx * S + S / 2;
const pixelY = boardRect.top + cy * S + S / 2;
// 清除爆炸范围内的所有格子
for (let r = cy - R; r <= cy + R; r++) {
for (let c = cx - R; c <= cx + R; c++) {
if (r >= 0 && r < CONFIG.ROWS && c >= 0 && c < CONFIG.COLS) {
board[r][c] = 0;
}
}
}
// 爆炸奖励分
score += CONFIG.BOMB_BONUS * level;
// 爆炸后触发消行检查
clearLines();
// 产生粒子特效
spawnParticles(pixelX, pixelY);
updateHUD();
}
// =====================
// 消行
// =====================
function clearLines() {
let cleared = 0;
for (let r = CONFIG.ROWS - 1; r >= 0; r--) {
if (board[r].every(cell => cell !== 0)) {
board.splice(r, 1);
board.unshift(Array(CONFIG.COLS).fill(0));
cleared++;
r++;
}
}
if (!cleared) return;
score += CONFIG.SCORE_TABLE[Math.min(cleared, 4)] * level;
lines += cleared;
const newLevel = Math.floor(lines / CONFIG.LINES_PER_LEVEL) + 1;
if (newLevel > level) {
level = newLevel;
dropInterval = Math.max(100, CONFIG.BASE_SPEED - (level-1) * CONFIG.SPEED_STEP);
}
updateHUD();
}
// =====================
// 粒子系统(爆炸特效)
// =====================
function spawnParticles(px, py) {
for (let i = 0; i < 30; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 3 + Math.random() * 6;
particles.push({
x: px, y: py,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 1.0,
color: `hsl(${Math.floor(Math.random()*60)}, 100%, 60%)`,
size: 3 + Math.random() * 4,
});
}
}
function updateParticles() {
expCtx.clearRect(0, 0, expCvs.width, expCvs.height);
particles = particles.filter(p => p.life > 0);
for (const p of particles) {
p.x += p.vx;
p.y += p.vy;
p.vy += 0.2; // 重力
p.life -= 0.03;
expCtx.globalAlpha = p.life;
expCtx.fillStyle = p.color;
expCtx.beginPath();
expCtx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
expCtx.fill();
}
expCtx.globalAlpha = 1;
}
// =====================
// 主循环
// =====================
function loop(ts) {
rafId = requestAnimationFrame(loop);
if (!running || paused) return;
const dt = ts - lastTs;
lastTs = ts;
dropAcc += dt;
if (dropAcc >= dropInterval) {
if (!hit(current, 0, 1)) current.y++;
else lock();
dropAcc = 0;
}
render();
updateParticles();
}
// =====================
// 键盘
// =====================
document.addEventListener('keydown', e => {
if (!running) return;
switch (e.key) {
case 'ArrowLeft': e.preventDefault(); if (!paused && !hit(current,-1,0)) current.x--; break;
case 'ArrowRight': e.preventDefault(); if (!paused && !hit(current, 1,0)) current.x++; break;
case 'ArrowDown':
e.preventDefault();
if (!paused) { if (!hit(current,0,1)) current.y++; else lock(); dropAcc = 0; }
break;
case 'ArrowUp': e.preventDefault(); if (!paused) tryRotate(); break;
case ' ':
e.preventDefault();
if (!paused) { while (!hit(current,0,1)) current.y++; lock(); }
break;
case 'p': case 'P': paused = !paused; break;
}
});
// =====================
// 渲染
// =====================
function render() {
const S = CONFIG.CELL;
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
for (let r = 0; r < CONFIG.ROWS; r++)
for (let c = 0; c < CONFIG.COLS; c++)
ctx.strokeRect(c*S, r*S, S, S);
for (let r = 0; r < CONFIG.ROWS; r++)
for (let c = 0; c < CONFIG.COLS; c++)
if (board[r][c]) drawCell(ctx, c, r, board[r][c], S);
// 虚影(炸弹不显示虚影)
if (!current.isBomb) {
const ghost = ghostPiece();
ghost.shape.forEach((row, r) =>
row.forEach((v, c) => {
if (!v) return;
ctx.fillStyle = 'rgba(255,255,255,0.1)';
ctx.fillRect((ghost.x+c)*S+1, (ghost.y+r)*S+1, S-2, S-2);
})
);
}
// 当前方块
current.shape.forEach((row, r) =>
row.forEach((v, c) => {
if (!v) return;
if (current.isBomb) {
drawBombCell(ctx, current.x+c, current.y+r, S);
} else {
drawCell(ctx, current.x+c, current.y+r, current.color, S);
}
})
);
if (paused) {
ctx.fillStyle = 'rgba(0,0,0,0.65)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff';
ctx.font = 'bold 28px Arial';
ctx.textAlign = 'center';
ctx.fillText('暂停', canvas.width/2, canvas.height/2);
ctx.font = '15px Arial';
ctx.fillStyle = '#aaa';
ctx.fillText('按 P 继续', canvas.width/2, canvas.height/2+34);
}
}
function drawCell(context, cx, cy, color, S) {
context.fillStyle = color;
context.fillRect(cx*S+1, cy*S+1, S-2, S-2);
context.fillStyle = 'rgba(255,255,255,0.18)';
context.fillRect(cx*S+1, cy*S+1, S-2, 4);
}
function drawBombCell(context, cx, cy, S) {
// 炸弹外观:深红底 + 💣 表情
context.fillStyle = '#2a0a0a';
context.fillRect(cx*S+1, cy*S+1, S-2, S-2);
context.strokeStyle = '#ff4500';
context.lineWidth = 2;
context.strokeRect(cx*S+2, cy*S+2, S-4, S-4);
context.font = `${S-8}px serif`;
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText('💣', cx*S + S/2, cy*S + S/2 + 1);
}
function ghostPiece() {
const g = { shape: current.shape, x: current.x, y: current.y };
while (!hit(g, 0, 1)) g.y++;
return g;
}
function drawNext() {
const S = 20;
nextCtx.fillStyle = '#16213e';
nextCtx.fillRect(0, 0, nextCvs.width, nextCvs.height);
if (next.isBomb) {
nextCtx.font = '36px serif';
nextCtx.textAlign = 'center';
nextCtx.textBaseline = 'middle';
nextCtx.fillText('💣', nextCvs.width/2, nextCvs.height/2);
return;
}
const ox = Math.floor((4 - next.shape[0].length) / 2);
const oy = Math.floor((4 - next.shape.length) / 2);
next.shape.forEach((row, r) =>
row.forEach((v, c) => {
if (!v) return;
nextCtx.fillStyle = next.color;
nextCtx.fillRect((ox+c)*S, (oy+r)*S, S-1, S-1);
})
);
}
function updateHUD() {
document.getElementById('score').textContent = score;
document.getElementById('level').textContent = level;
document.getElementById('lines').textContent = lines;
}
function hardDrop() {
while (!hit(current, 0, 1)) current.y++;
lock();
}
function endGame() {
running = false;
document.getElementById('final-score').textContent = score;
document.getElementById('overlay').classList.add('show');
}
startGame();
</script>
</body>
</html>