feat: AICODE-06 春季后半 7 课大纲 + 第 12 课教案 + prototype 工程产物
## 主要变更 ### 课程设计 - 大纲扩展到 18 课(新增第 12-18 课:单词塔防 3D 大项目) - 引入 AI 三角色协作工作流(Planner / Reviewer / Tester)作为整学期框架 - 每课详化:核心概念 + 误概念预设 + 教学锚点 + 学生产出 + 老师课前要准备 ### 第 12 课教案(完整逐字稿) - 主题:Skills 入门 - 用 game-studio 做跳一跳 - 90 分钟 4C 结构 + 诊断点 + 分支策略 - 5 个误概念预设 + AI 助教提示词模板 + 教师备课指南 ### prototype 工程产物(可玩 demo) - 跳一跳-3d/index.html:Three.js 3D 跳一跳(蓄力 + Web Audio 音效 + PERFECT 命中) - 单词塔防/game-3d.html:完整 3D 塔防(三阶段 + 商店 + 卡片 + 战斗循环,15 击杀完美胜利) - 单词塔防/level-editor-3d.html:3D 关卡设计器(Kenney GLB 模型 + localStorage 保存) - 单词塔防/level-editor.html:2D 关卡设计器(原型保留) - 单词塔防/index.html:2D 塔防原型(原型保留) ### 工程加固 - .gitignore 加强:排除 token、Kenney 大素材、调试截图、第三方插件、Playwright 临时 - 从 git tracking 移除 scripts/.dingtalk_token.json(本地保留) - scripts/sync_to_dingtalk.py:OAuth 流程改为手动 authCode 粘贴(避免本地 server 受限) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
32
.gitignore
vendored
32
.gitignore
vendored
@@ -27,3 +27,35 @@ Desktop.ini
|
||||
# Claude Code 本地缓存
|
||||
.claude/settings.local.json
|
||||
old/
|
||||
|
||||
# 敏感文件(token、密钥)
|
||||
scripts/.dingtalk_token.json
|
||||
scripts/__pycache__/
|
||||
|
||||
# Playwright / 自动化测试临时
|
||||
.playwright-mcp/
|
||||
|
||||
# 第三方插件(独立仓库,不进项目)
|
||||
game-studio/
|
||||
|
||||
# Prototype 大素材包(用户自行下载,不进 Git)
|
||||
prototype/*/assets/kenney-*/
|
||||
|
||||
# Prototype 开发期工具截图(非生产产物)
|
||||
prototype/单词塔防/sprite-grid.png
|
||||
prototype/单词塔防/grid-*.png
|
||||
prototype/单词塔防/sprite-browser-full.png
|
||||
|
||||
# 根目录调试截图(测试用)
|
||||
/3d-*.png
|
||||
/jump-3d-*.png
|
||||
/demo-*.png
|
||||
/battle-*.png
|
||||
/level-editor-*.png
|
||||
/skeleton-*.png
|
||||
/sprite-browser-full.png
|
||||
/v0[0-9]-*.png
|
||||
/deploy-loaded.png
|
||||
|
||||
# 豆包/AI 生图(用户本地实验素材)
|
||||
/像素风*.png
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AICODE-06 课程大纲
|
||||
|
||||
> AI编程创新课 · 06路线 · 春季学期(前5课独立,合流时间点待定)
|
||||
> AI编程创新课 · 06路线 · 春季学期(共18课:1-5课衔接 + 6-7俄罗斯方块 + 8-11涂鸦PK + 12-18单词塔防大项目,合流时间点待定)
|
||||
> 适用对象:小学6年级起点,有扣子/低代码经验
|
||||
|
||||
---
|
||||
@@ -17,8 +17,6 @@
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 项目实战阶段:魔幻俄罗斯方块(第6-7课)
|
||||
|
||||
> 面向已完成前5课的 AICODE-06 学员。以俄罗斯方块为载体,系统训练工程师思维:Plan Mode 先行、需求审核、自动测试、新窗口原则。
|
||||
@@ -71,6 +69,425 @@ AI生成测试脚本(新窗口D:自动测试)
|
||||
|
||||
---
|
||||
|
||||
## 项目实战阶段:单词塔防 3D(第12-18课)
|
||||
|
||||
> 春季后半学期主线大项目。**整学期 7 课挂同一个项目**,从概念引入到完整发布。引入 **Subagent**(独立 AI 工人)+ **Skills**(AI 专业知识包)两个核心新概念,把"AI 工程方法论"作为教学主轴。学科载体:英语单词(家长心智里的硬需求)+ 3D 塔防(视觉震撼、家长接受度高)。
|
||||
|
||||
### 整体架构
|
||||
|
||||
```
|
||||
概念入门(12)→ 工程方法(13)→ 需求驱动(14)→ 功能迭代(15-17)→ 发布(18)
|
||||
```
|
||||
|
||||
### 🌟 AI 三角色协作工作流(七课终极愿景)
|
||||
|
||||
整个项目最终要让学生掌握的**核心工程范式** — **管理一支 AI 团队,而不是自己写代码**。
|
||||
|
||||
整学期通过 7 节课逐步引入并整合 **3 个 AI 角色**:
|
||||
|
||||
| 角色 | 职责 | 第几课正式引入 |
|
||||
|---|---|---|
|
||||
| 🧑💼 **Planner(计划员)** | 把模糊想法变成结构化 PRD(产品需求文档),指挥工作流方向 | 第 14 课正式登场 |
|
||||
| 🔍 **Reviewer(审核员)** | 在独立上下文里审查代码、PRD、计划,挑出问题 | 第 13 课正式登场 |
|
||||
| 🧪 **Tester(测试员)** | 用 Playwright 自动测试游戏功能,生成测试报告 | 第 15 课正式登场 |
|
||||
|
||||
**关键工程哲学**:
|
||||
- 用 **Subagent 机制**让这 3 个角色拥有**独立上下文窗口**——互不污染、各管一片
|
||||
- 主 AI 是"项目经理",学生是"老板",**老板只发号施令**,3 个 AI 协作出成果
|
||||
- 工作流自动化:**写计划 → 审计划 → 编码 → 审代码 → 测试 → 修复 → 再测试**,全程 AI 协作
|
||||
|
||||
**为什么必须强调这套工作流**:
|
||||
- 国产模型(豆包/Kimi/通义/混元)**往往不能一击必胜**,需要审 → 改 → 再审
|
||||
- 手动复制粘贴换窗口审核**太累、太慢、容易出错**
|
||||
- 用 Subagent 让审核**自动化** — 学生说一句"召唤 Reviewer 审一下",AI 自己完成
|
||||
- **这才是真实工程师 2026 年的工作方式**,不是抽象未来,是当下行业范式
|
||||
|
||||
### 各课大纲
|
||||
|
||||
| 课时 | 课程主题 | 学习目标 | 核心新概念 | 核心产出 |
|
||||
|:----:|---------|---------|----------|---------|
|
||||
| 12 | **Skills 入门 — 用 game-studio 做跳一跳** | • 理解 Skills 本质 = 给 AI 装"专业知识包"<br>• 装 game-studio 插件 → 体验"装 vs 不装"代码差异<br>• 用 `phaser-2d-game` skill 让 AI 做出可玩的跳一跳 | **Skills**(食谱类比)+ **game-studio 插件** + **phaser-2d-game skill** | 🎮 可玩的跳一跳 .html(小球能跳 + 平台 + 计分)+ 🧠 Skills 概念笔记 |
|
||||
| 13 | **Subagent + 塔防 v0.1 — 最简能玩版本** | • 理解 Subagent 本质 = **独立上下文**(为什么要"另起炉灶"做审代码)<br>• 召唤 Code Review subagent 审第 12 课跳一跳代码<br>• 用 3D 关卡设计器搭自己的塔防地图<br>• **用刚做的地图 + 1 个塔做出原始塔防 v0.1**(自动开火,可击杀怪) | **Subagent**(独立上下文 / 审代码工程师)+ **Code Review subagent** + 3D 编辑器 + **塔防 v0.1(1 塔 1 怪类型)** | 🔍 被 review 优化过的跳一跳代码 + 🗺️ 个人 3D 地图 + 🗼 **最简塔防 v0.1(1 塔自动攻击 + 怪沿路径走 + 击杀计数)**|
|
||||
| 14 | **项目深度 PRD — 引入"单词当子弹"核心机制** | • 在 v0.1 基础上做**项目级 PRD**(完整塔防设计文档)<br>• **首次引入项目核心机制**:英语单词当塔的子弹/弹药<br>• 召唤 Planner subagent 协助写 PRD,Reviewer 审 PRD | **项目级 PRD** + **Planner subagent 登场** + **单词卡片 = 子弹机制** | 📝 项目 PRD.md(覆盖词库系统/卡片机制/塔类型/胜负条件)+ 🎮 塔防 v0.2(**塔需要单词卡片才能开火**,核心机制成型)|
|
||||
| 15 | **塔战斗系统 + Web Audio 音效** | • 实现 3 种塔(单发/冰冻/链式)+ 子弹追踪<br>• 用 Web Audio API 合成战斗音效(零素材)<br>• 用 `game-playtest` skill 自动测试 | **多塔差异化逻辑**、Web Audio 音效合成、playtest skill | 3 种塔完整战斗 + 命中/击杀音效 + 自测报告 |
|
||||
| 16 | **波次系统 + 难度梯度** | • 数据驱动的波次配置(`waves.json`)<br>• 关卡难度递进设计(怪速/血量/数量)<br>• 金币经济与塔商店调优 | **数据驱动设计**、关卡平衡 | 多波次关卡(简单→中等→Boss)+ 个人 `waves.json` |
|
||||
| 17 | **词库系统 + 个性化主题** | • 设计 JSON 词库(英语/古诗/历史/地理可选)<br>• 让塔防绑定 TA 的兴趣领域<br>• 兼容老师班级词库 + 学生自定义 | **学生作品 IP 化**、JSON 数据扩展 | 个人词库 JSON(30-50 词,自选学科)+ 塔防可玩自家词库 |
|
||||
| 18 | **🎉 班级发布会 + Electron 打包** | • Electron 打包成 .exe 桌面应用<br>• 加 LICENSE.txt(CC0/MIT 协议入门)<br>• 3 分钟路演 + 家长开放日 | **桌面应用打包**(秋季多文件项目入口)、**开源协议初识** | 个人塔防 .exe 可执行文件 + 路演 + 家长开放日 |
|
||||
|
||||
### 七课核心工作流
|
||||
|
||||
```
|
||||
Skills + game-studio + 跳一跳(先动手做/12)
|
||||
↓
|
||||
Subagent + Code Review + 3D 关卡设计(独立上下文/13)
|
||||
↓
|
||||
PRD 驱动 → AI 实现(需求驱动:14)
|
||||
↓
|
||||
Web Audio + Playtest skill(视听反馈+自测:15)
|
||||
↓
|
||||
数据驱动波次配置(关卡设计:16)
|
||||
↓
|
||||
学生自选学科词库(IP 化:17)
|
||||
↓
|
||||
Electron 打包 + 版权协议 + 家长开放日(发布:18)
|
||||
```
|
||||
|
||||
### 老师备课资产清单
|
||||
|
||||
整套教学需要老师提前准备:
|
||||
- **3D 编辑器示例地图包**(5 张 JSON,覆盖简单/中等/Boss/雪地/峡谷)→ 第 13 课学生导入
|
||||
- **示例 PRD 模板**(《我的塔防 PRD》样板)→ 第 14 课学生参考
|
||||
- **默认词库**(学校用品 12 词 + 古诗 12 句 + 中国朝代 12 个)→ 第 17 课学生扩展起点
|
||||
- **波次配置示例**(`waves.json` 默认 5 波)→ 第 16 课学生改造
|
||||
- **Electron 打包脚本**(一键打包 `.exe`)→ 第 18 课直接套用
|
||||
|
||||
### 第 12 课:Skills 入门 + 跳一跳 — 具体教法(关键)
|
||||
|
||||
**核心理念**:先让学生**看到 AI 真的能做出游戏**的体感(具体),再讲 Skills 是什么(抽象)。**从动手到理论**是更自然的学习路径。
|
||||
|
||||
**Skills 怎么教**(分三步走,对比演示法):
|
||||
|
||||
1. **演示痛点**(不装 game-studio):
|
||||
- 让主 AI 写一个"跳一跳"游戏
|
||||
- 输出:**代码乱、跑不起来、视觉差** — AI 不懂 Phaser 的最佳实践
|
||||
- 揭秘:"AI 知道一些基础知识,但不懂游戏开发的专业套路"
|
||||
|
||||
2. **Skills 救场**(装 game-studio 插件):
|
||||
- 一行命令装上 game-studio 插件
|
||||
- 让主 AI 用 `phaser-2d-game` skill 重新做跳一跳
|
||||
- 输出:**代码整洁、能跑、视觉漂亮** — AI 突然"专业"了
|
||||
|
||||
3. **学生 Aha 时刻**:
|
||||
- "Skills 是给 AI 装的'专业教材',装了 AI 就突然会做这件事了"
|
||||
- **类比固化**:Skill = 食谱,AI = 厨师,**厨师拿到食谱才能做出菜**
|
||||
|
||||
**第 12 课作品**(可玩的跳一跳):
|
||||
- 学生自己装 game-studio 插件(`/plugin install game-studio`)
|
||||
- 让 AI 用 `phaser-2d-game` skill 做跳一跳基础版:
|
||||
- 小球可以按空格跳跃
|
||||
- 平台从右向左移动
|
||||
- 落到平台得分,落水/出屏游戏结束
|
||||
- 学生**个性化定制**:换背景色、改小球形状、加自己设计的音效
|
||||
- 课程结尾:**互相玩对方的跳一跳**(榜单 / 谁分高)
|
||||
|
||||
**为什么跳一跳是 Skills 教学的最强载体**:
|
||||
- ✅ **视觉震撼**:小球真的能跳,平台真的能动,分数真的能涨
|
||||
- ✅ **对比强烈**:不装 game-studio 时翻车,装上后秒做 — Skills 价值一目了然
|
||||
- ✅ **小学生秒懂玩法**:微信跳一跳全民共识,不需要讲解
|
||||
- ✅ **课堂可玩**:90 分钟末尾可以互玩对方的版本,情感高潮收尾
|
||||
- ✅ **零素材依赖**:纯几何 + Web Audio 音效,不需要 Kenney 等外部资源
|
||||
|
||||
**13 课伏笔**:
|
||||
- 课末预告:"你的跳一跳代码可能不够干净。下节课**我们会召唤另一个 AI(Subagent)来审你的代码** — 它会有独立的"工程师视角",发现你和主 AI 都没看到的问题。"
|
||||
|
||||
---
|
||||
|
||||
### 第 13 课:Subagent + 塔防 v0.1 — 最简能玩版本
|
||||
|
||||
**核心新概念**:
|
||||
- **Subagent(子智能体)**:AI 召唤的另一个 AI 实例,**有独立上下文窗口**,跟主 AI **互不污染**
|
||||
- **为什么需要 Subagent**:国产模型不一击必胜,需要审核迭代;手动复制粘贴换窗口太累 → Subagent 自动化
|
||||
- **Reviewer 角色**正式登场(三角色协作第一步)
|
||||
- 🔥 **塔防 v0.1**(最简能玩版本):用刚做的地图 + **1 个塔自动开火** + 怪沿路径走 — **学生离开教室时手上有真正的塔防雏形**,不是工具
|
||||
|
||||
**误概念预设(老师写教案时必须预防)**:
|
||||
- M1:"Subagent 是更厉害的 AI" → 错。Subagent **跟主 AI 是同一个模型**,只是独立窗口
|
||||
- M2:"Subagent 永远比主 AI 强" → 错。Subagent 优势在**独立上下文**,不是能力更强
|
||||
- M3:"做塔防需要先写完整 PRD" → 错。**13 课先快速跑一个 v0.1 体感版**;真正的项目 PRD 是第 14 课的事
|
||||
- M4:"塔防 v0.1 只 1 个塔太简单" → 错。**最简能玩 > 复杂不可玩**。先有"动起来的塔防",学生才有继续投入的动力
|
||||
|
||||
**教学锚点(Aha 时刻设计)**:
|
||||
|
||||
1. **痛点演示**(老师故意手动):
|
||||
- 拿第 12 课跳一跳代码 → 复制 → 打开新窗口 → 粘贴 → 让 AI 审 → 复制意见 → 回主窗口 → 粘 → 让 AI 改 → 又复制 → 又粘......
|
||||
- 让学生看 5 分钟"老师在重复复制粘贴"的痛苦表情 → 引出"有更好的办法吗?"
|
||||
|
||||
2. **Subagent 救场**:
|
||||
- 主 AI 里直接说:"召唤一个 Reviewer subagent 来审这段代码"
|
||||
- 学生看到 subagent **自动审**、**自动给意见**、**主 AI 自动改**——一句话搞定刚才 5 分钟的痛苦
|
||||
|
||||
3. **独立上下文演示**:
|
||||
- 让 subagent 审一段代码后,问主 AI:"刚才 subagent 看了什么?"
|
||||
- 主 AI 答:"我不知道"——证明**主 AI 看不到 subagent 的对话历史**
|
||||
- 学生 Aha:"两个 AI 是真的独立的!"
|
||||
|
||||
4. 🎯 **塔防 v0.1 闭环演示**(本课情感高潮):
|
||||
- 学生用 3D 编辑器搭出自己的地图(可以从老师备课包导入再改)
|
||||
- 用 game-studio 的 three-webgl-game skill 加 1 个塔 + 怪物路径
|
||||
- **塔自动开火 → 怪被击杀 → 计数器 +1**
|
||||
- 学生**第一次看到自己的塔防雏形动起来** — 哪怕只有 1 个塔、1 种怪、没有音效、没有 UI
|
||||
- 这一刻的成就感 **远超"我只做了一张地图"**
|
||||
|
||||
**教学节奏建议(90 分钟)**:
|
||||
- Connect 10':展示第 12 课跳一跳 + 痛点演示(复制粘贴累)
|
||||
- Construct 65':
|
||||
- 分段 1 (15'):Subagent 概念演示(独立上下文)+ Code Review subagent 审跳一跳
|
||||
- 分段 2 (15'):3D 关卡设计器 — 学生搭自己的地图
|
||||
- 分段 3 (15'):**用 1 个塔 + 怪物路径,跑出最简塔防 v0.1**(主菜)
|
||||
- 分段 4 (20'):学生定制(改塔的位置 / 改怪物速度 / 加 1 种装饰)
|
||||
- Contemplate 10':互玩对方塔防 + 抽象总结
|
||||
- Continue 5':预告 14 课 — 真正写**项目级 PRD** + 引入"单词当子弹"机制
|
||||
|
||||
**学生产出**(3 个,逐级递进):
|
||||
- 🔍 跳一跳代码经过 Code Review subagent 优化(更整洁)
|
||||
- 🗺️ 用 3D 关卡设计器搭的自己的地图(JSON)
|
||||
- 🗼 **塔防 v0.1 雏形**(1 塔 + 自动开火 + 怪沿路径 + 击杀计数)
|
||||
|
||||
**衔接**:
|
||||
- ← 第 12 课:你装了 Skill 让 AI 变专业,做出了跳一跳
|
||||
- → 第 14 课:**塔防 v0.1 太简陋了** — 没有用户输入、没有玩家挑战。**用项目级 PRD 把它变成真正的"单词塔防"** — 召唤 Planner 角色
|
||||
|
||||
**老师课前要准备**:
|
||||
- **塔防 v0.1 代码骨架**(`prototype/单词塔防/game-3d.html` 的简化版 — 只保留:加载地图 + 1 个塔 + 怪沿路径 + 自动开火 + 计数)
|
||||
- 一段"故意有问题"的跳一跳代码(变量名乱、有 magic number)用作 Code Review 演示对象
|
||||
- 3D 关卡设计器的 5 张示例地图 JSON(`level-pack/sample-*.json`)
|
||||
- 简化版"装上塔"的 prompt 模板(给学生用,降低门槛)
|
||||
|
||||
---
|
||||
|
||||
### 第 14 课:项目深度 PRD — 引入"单词当子弹"核心机制(Planner 登场)
|
||||
|
||||
**核心新概念**:
|
||||
- **项目级 PRD(深度需求文档)**:不再是"做一座塔"的小 PRD,而是**整个塔防项目的完整需求文档**——覆盖词库系统、卡片机制、塔类型、胜负条件、视觉风格
|
||||
- **Planner subagent**:专门写 PRD 的"AI 产品经理",在独立上下文里**结构化输出**
|
||||
- 🌟 **项目核心机制**:**单词卡片 = 塔的子弹/弹药** — 学生先输英语单词获得"卡片",卡片装到塔上当弹药。这是单词塔防的灵魂机制
|
||||
- **PRD → 代码 → Review → 测试 → 迭代** 的完整闭环
|
||||
|
||||
**误概念预设**:
|
||||
- M1:"PRD 太麻烦,直接告诉 AI 就行" → 错。**13 课就是这种'直接告诉'的产物 — 简陋**。项目级要 PRD
|
||||
- M2:"PRD 越长越好" → 错。**清晰、可执行、覆盖关键边界**比长更重要(老师示范一份精简 PRD 模板)
|
||||
- M3:"单词当子弹这个机制太怪了" → 错。**这是穹狼塔防的独特机制** — 把"学单词"和"游戏弹药管理"自然耦合,这是教育游戏的关键创新
|
||||
- M4:"AI 写代码我看不懂" → 错。**你要会读 PRD 验收 AI**,不一定会写代码
|
||||
|
||||
**教学锚点**:
|
||||
|
||||
1. **回顾 13 课 v0.1 的局限**:
|
||||
- 老师展示一个 v0.1 给学生玩 30 秒
|
||||
- 抛问题:"你觉得现在的塔防缺什么?"
|
||||
- 学生回应:"没玩家操作"、"没意思"、"没有单词怎么叫单词塔防"
|
||||
- 引出今天:**把灵魂机制加进去 — 单词当子弹**
|
||||
|
||||
2. **对比演示 — 无 PRD vs 有 PRD**:
|
||||
- 演示 A:跟 AI 说"加单词机制" → 输出五花八门(可能是飘字、可能是问答、可能是其他)
|
||||
- 演示 B:写完整 PRD("玩家输入英文 → 获得卡片 → 卡片装到塔上 → 塔用卡片当子弹开火 → 每张卡 N 发子弹") → AI 输出**精准的卡片机制**
|
||||
- 学生 Aha:**PRD 越详细,AI 越听话**
|
||||
|
||||
3. **Planner subagent 登场**:
|
||||
- 主 AI 说:"召唤 Planner 帮我写'单词塔防'的完整 PRD"
|
||||
- Planner 在独立上下文里输出**结构化文档**:词库 schema / 卡片机制 / 塔规格 / 胜负条件
|
||||
- 学生看到 Planner + Reviewer 协作(Reviewer 审 PRD,挑出"边界情况没覆盖")
|
||||
- **第一次看到 3 个角色中的 2 个完整协作**(Planner + Reviewer)
|
||||
|
||||
4. **PRD → 代码 → 玩**:
|
||||
- 把 PRD 喂给主 AI,主 AI 在 v0.1 基础上**增量加单词机制**
|
||||
- 学生玩 v0.2:**输入 "apple" → 卡片出现 → 装到塔 → 塔开火 → 怪死掉**
|
||||
- 完整核心循环成型
|
||||
|
||||
**学生产出**:
|
||||
- 📝 项目 PRD.md(800-1500 字,覆盖:词库 schema / 卡片机制 / 塔规格 / 胜负 / 视觉)
|
||||
- 🎮 **塔防 v0.2**(核心机制成型 — 学生输单词 + 卡片当子弹 + 塔自动开火)
|
||||
|
||||
**衔接**:
|
||||
- ← 第 13 课:v0.1 太简陋,**没玩家操作、没单词** — 不是真正的单词塔防
|
||||
- → 第 15 课:核心机制有了,但**只 1 种塔、没音效、没自动测试**——下节课加战斗多样性 + Tester 登场
|
||||
|
||||
**老师课前要准备**:
|
||||
- **完整 PRD 示例**(老师写好的"单词塔防 PRD" 模板 — 1500 字左右)
|
||||
- PRD 评分 rubric(覆盖度 / 可执行度 / 边界情况)
|
||||
- v0.1 代码骨架(13 课的产物,基础上加单词机制)
|
||||
- Planner subagent 调用 prompt 模板
|
||||
|
||||
---
|
||||
|
||||
### 第 15 课:战斗系统 + 音效 + Playtest skill(Tester 角色登场)
|
||||
|
||||
**核心新概念**:
|
||||
- **多塔类型差异化**:同一个"开火"接口,3 种塔(单发/冰冻/链式)不同效果
|
||||
- **Web Audio API**:用代码合成音效(回收涂鸦PK 第 10 课经验)
|
||||
- **game-playtest skill**:让 AI 用 Playwright 自动测试游戏
|
||||
- **Tester 角色**正式登场(三角色协作完整)
|
||||
|
||||
**误概念预设**:
|
||||
- M1:"音效要找 mp3 文件下载" → 错。Web Audio 可以**纯代码合成**,零文件依赖
|
||||
- M2:"测试是写完代码后才做的事" → 错。**写代码前先想测试**——Tester 视角让设计更稳
|
||||
- M3:"自动测试 = 替代手动玩" → 错。**自动测试覆盖回归**(改一次代码不破坏老功能),手动玩负责"好不好玩"
|
||||
- M4:"三个塔都用一样的方法" → 错。**多塔差异化** = 设计技能,不是技术问题
|
||||
|
||||
**教学锚点**:
|
||||
|
||||
1. **战斗回路演示**:
|
||||
- 主 AI 用 PRD 实现"魔法塔基础版" + "冰冻塔变体(加减速 buff)" + "链式塔变体(命中后链击)"
|
||||
- 学生看到**同一接口、3 种行为** — 设计模式启蒙
|
||||
|
||||
2. **Web Audio 音效合成回收**:
|
||||
- 回顾涂鸦PK 第 10 课:`playTone(freq, duration, type, volume)`
|
||||
- 学生为塔加 5 种音效(开火/命中/击杀/冰冻/连击)
|
||||
- **零文件依赖,纯代码合成**
|
||||
|
||||
3. **Tester subagent 登场**:
|
||||
- 主 AI 说:"召唤 Tester 用 Playwright 测一下战斗系统"
|
||||
- Tester 在独立上下文里**自动玩游戏**:启动战斗 → 等待 30 秒 → 检查 HP/击杀/塔状态 → 报告
|
||||
- 学生第一次看到 **3 个 AI 角色完整协作**:Planner(写计划)+ 主 AI(写代码)+ Reviewer(审代码)+ Tester(测试)
|
||||
|
||||
**学生产出**:
|
||||
- ⚔️ 完整战斗系统(3 种塔 + 子弹追踪 + HP 系统)
|
||||
- 🔊 战斗音效完整(开火/命中/击杀/冰冻/连击)
|
||||
- 🧪 Tester 自动测试报告(Playwright 跑通战斗循环)
|
||||
|
||||
**衔接**:
|
||||
- ← 第 14 课:你有一座基础塔,但**战斗单调**
|
||||
- → 第 16 课:战斗能打了,但**怪物太弱、波次太单调**——下节课加难度梯度
|
||||
|
||||
**老师课前要准备**:
|
||||
- 完整战斗 PRD 模板(3 种塔的差异化规格)
|
||||
- Web Audio 音效配方卡片(频率/波形对应表)
|
||||
- Playwright Tester 的 prompt 模板("启动战斗 → 等 N 秒 → 检查 X")
|
||||
|
||||
---
|
||||
|
||||
### 第 16 课:波次系统 + 难度梯度(数据驱动)
|
||||
|
||||
**核心新概念**:
|
||||
- **数据驱动设计**:`waves.json` 描述每波难度,改 JSON = 改难度,**不改代码**(回收涂鸦PK 第 11 课"加文件=加角色")
|
||||
- **关卡平衡**:怪速 / 怪 HP / 出怪间隔 / 总数 — 这 4 个数字决定关卡难度
|
||||
- **递进难度曲线**:简单波 → 中等波 → BOSS 波 → 多波渐进
|
||||
|
||||
**误概念预设**:
|
||||
- M1:"难度就是怪多" → 错。难度有 4 个维度,**叠加才有层次**
|
||||
- M2:"波次设计随便写就行" → 错。**第 1 波必须能通**(让玩家上手),第 5 波必须刺激(高潮)
|
||||
- M3:"BOSS 就是高 HP 怪" → 错。BOSS = 设计专属机制(分裂/隐身/反弹)
|
||||
- M4:"改难度要重写代码" → 错。**改 `waves.json` 即可**,这就是数据驱动
|
||||
|
||||
**教学锚点**:
|
||||
|
||||
1. **数据驱动概念回收**:
|
||||
- 回顾涂鸦PK 第 11 课:"加文件=加角色"
|
||||
- 演示:在 `waves.json` 加一段 `{"speed": 2.0, "hp": 3}` → 立刻新增一波,**代码不动**
|
||||
|
||||
2. **关卡平衡迭代**:
|
||||
- 学生设计自己的 5 波难度梯度
|
||||
- 召唤 Tester subagent 测每一波是否平衡(总能赢但有挑战)
|
||||
- 不平衡 → 调 `waves.json` → 再测
|
||||
|
||||
3. **BOSS 设计启蒙**:
|
||||
- BOSS 不是"超强怪",是"有专属机制的怪"
|
||||
- 学生设计 1 个 BOSS:**至少有 1 个特殊机制**(分裂/隐身/反弹/招小怪)
|
||||
|
||||
**学生产出**:
|
||||
- 🌊 个人 `waves.json`(5 波递进难度 + 1 个 BOSS)
|
||||
- 📊 测试报告:每波的击杀数 / HP 损失 / 完成时间(Tester 自动跑)
|
||||
|
||||
**衔接**:
|
||||
- ← 第 15 课:你的战斗能打,但**只有一波怪**
|
||||
- → 第 17 课:关卡有梯度了,但**词库还是英语单词**——下节课让学生选自己学科
|
||||
|
||||
**老师课前要准备**:
|
||||
- `waves.json` 模板(默认 5 波 + 1 BOSS 配置)
|
||||
- Tester 平衡测试 prompt 模板
|
||||
|
||||
---
|
||||
|
||||
### 第 17 课:词库系统 + 个性化主题(学生 IP 化)
|
||||
|
||||
**核心新概念**:
|
||||
- **词库 = JSON 数据**:`words.json` 描述学科词汇,游戏从这里读题
|
||||
- **学科多样性**:学生选自己感兴趣的领域(英语/古诗/历史/地理/化学)
|
||||
- **学生作品 IP 化**:每个学生的塔防绑定 ta 的兴趣
|
||||
|
||||
**误概念预设**:
|
||||
- M1:"只能做英语单词" → 错。**词库可以是任何学科**,只要有"中文 → 答案"的对应
|
||||
- M2:"自己选学科会让游戏 hint 不一致" → 错。**词库统一 schema**,游戏代码不变,数据驱动
|
||||
- M3:"词库越大越好" → 错。**30-50 词足够一局战斗**,质量 > 数量
|
||||
- M4:"老师必须给词库" → 错。**学生自己设计**才是核心训练 — 培养"知识工程师"思维
|
||||
|
||||
**教学锚点**:
|
||||
|
||||
1. **词库结构剖析**:
|
||||
- 看 `words.json` 的 schema:`[{question: "苹果", answer: "apple", emoji: "🍎"}, ...]`
|
||||
- 任何学科都能套这个结构
|
||||
- 英语:`{question: "苹果", answer: "apple"}`
|
||||
- 古诗:`{question: "床前明月光,___", answer: "疑是地上霜"}`
|
||||
- 历史:`{question: "唐朝建立者是?", answer: "李渊"}`
|
||||
- 地理:`{question: "广东省会?", answer: "广州"}`
|
||||
|
||||
2. **学生选学科 + 写词库**:
|
||||
- 每个学生选自己最熟/最爱的学科
|
||||
- 写至少 30 词,导入游戏
|
||||
- 玩自己的塔防
|
||||
|
||||
3. **班级词库交换**:
|
||||
- 互玩别人的词库 → "原来我数学不太好的同学做了一个超难数学塔防"
|
||||
- **跨学科学习的自然发生**
|
||||
|
||||
**学生产出**:
|
||||
- 📖 个人 `words.json`(30-50 词,自选学科)
|
||||
- 🎯 塔防绑定自己词库,可玩
|
||||
|
||||
**衔接**:
|
||||
- ← 第 16 课:你的关卡有梯度,但**怪物身上的内容是老师给的**
|
||||
- → 第 18 课:你的塔防完整了,**怎么让家长玩到?** 下节课打包成 .exe + 班级发布会
|
||||
|
||||
**老师课前要准备**:
|
||||
- 多学科词库示例(英语/古诗/历史/地理 各 1 个 sample)
|
||||
- `words.json` 统一 schema 文档
|
||||
|
||||
---
|
||||
|
||||
### 第 18 课:Electron 打包 + 班级发布会(发布 + 版权)
|
||||
|
||||
**核心新概念**:
|
||||
- **Electron 打包**:Web 游戏变成桌面 `.exe`/`.app`,**双击运行,不需要浏览器**
|
||||
- **开源协议**(LICENSE):Kenney 素材 CC0 / Phaser/Three.js MIT,**用别人的东西要标注**
|
||||
- **路演 / 发布会**:**讲设计决策**而非功能列表(回收涂鸦PK 第 11 课)
|
||||
- **跨届 IP 资产**:学生作品进入"穹狼塔防作品集",**学长学姐的作品成为下一届的遗产**
|
||||
|
||||
**误概念预设**:
|
||||
- M1:"打包是另一个项目,跟我现在的塔防无关" → 错。**Electron 直接加载你的 game-3d.html**,无侵入
|
||||
- M2:"用了 Kenney 的素材就是我的" → 错。**CC0 协议虽然允许商用,但建议标注来源**——这是工程师道德
|
||||
- M3:"路演就是把游戏跑给家长看" → 错。**讲设计决策**比放游戏更重要——"我为什么做这个塔,我学了什么"
|
||||
- M4:"发布会是结束" → 错。**这是穹狼塔防 IP 的开始**——下一届学生会基于你的作品继续发展
|
||||
|
||||
**教学锚点**:
|
||||
|
||||
1. **Electron 打包演示**:
|
||||
- 一行命令 `npx electron-packager .` 或类似
|
||||
- 学生看到自己的 `index.html` 变成 `MyTowerDefense.exe`
|
||||
- 双击运行,**就是一个独立桌面游戏**
|
||||
|
||||
2. **版权 + LICENSE 教育**:
|
||||
- 解释 CC0 和 MIT 协议(用孩子能懂的话)
|
||||
- 学生写 `LICENSE.txt`:"我的代码 MIT 协议 / 美术 Kenney CC0 / 引擎 Three.js MIT"
|
||||
- **第一次接触工程师道德/法律基础**
|
||||
|
||||
3. **班级发布会**:
|
||||
- 每个学生 3 分钟路演:"我的塔防是什么、为什么这么设计、过程中遇到什么、最得意的设计是什么"
|
||||
- **邀请家长**到场
|
||||
- 家长玩学生的塔防(角色反转 — 孩子考家长)
|
||||
- 评出"最有创意"、"最难"、"最好玩"等奖项
|
||||
|
||||
4. **跨届 IP 启动**:
|
||||
- 学生作品打包进"穹狼塔防作品集"目录,**作为穹狼科创资产**
|
||||
- 下一届学生会玩到这些作品,**作为下一届的起点**
|
||||
- **时间复利** + **学生归属感** 的种子在这一刻种下
|
||||
|
||||
**学生产出**:
|
||||
- 💾 个人塔防 `.exe`(可双击运行的桌面游戏)
|
||||
- 📜 个人 LICENSE.txt(标注代码 + 素材 + 引擎来源)
|
||||
- 🎤 3 分钟路演 + 家长玩自己作品的视频
|
||||
- 🏆 班级评奖(各种奖项,人人有奖)
|
||||
|
||||
**衔接**:
|
||||
- ← 第 17 课:你的塔防完整 + 个性化
|
||||
- → 秋季:多文件项目结构 + Electron 深入 + 更复杂的项目(**第 18 课是秋季入口的伏笔**)
|
||||
|
||||
**老师课前要准备**:
|
||||
- Electron 打包脚本(一键 `package.json` + main.js)
|
||||
- 班级发布会场地 + 家长邀请函
|
||||
- 评奖证书模板
|
||||
- 跨届 IP 资产目录("穹狼塔防作品集"git 仓库或共享目录)
|
||||
|
||||
---
|
||||
|
||||
## 合流说明
|
||||
|
||||
> **合流时间点待定。** 原计划第5课合流,但考虑到 AICODE-03 学生打字和表达能力的成长节奏,合流点可能后延。
|
||||
|
||||
517
3-lessons/AICODE-06/AICODE06-12 Skills入门-跳一跳.md
Normal file
517
3-lessons/AICODE-06/AICODE06-12 Skills入门-跳一跳.md
Normal file
@@ -0,0 +1,517 @@
|
||||
---
|
||||
课时: 12
|
||||
主题: Skills 入门 — 用 game-studio 做跳一跳
|
||||
核心能力: [提问力, 韧性力]
|
||||
核心工具: [QonnwolfCode/Trae, game-studio 插件, Three.js]
|
||||
时长: 90分钟
|
||||
透明化层级: 过程层
|
||||
适用路线: AICODE-06
|
||||
---
|
||||
|
||||
### 1. 课程目标
|
||||
|
||||
**知识目标:**
|
||||
- 理解 **Skills 的本质 = 工具箱**:给 AI 装的"专业工具",装了 AI 就能在那个领域调用专业方法,**不用自己造轮子**
|
||||
- 理解 **AI 不是无所不能**:通用 AI 知道一点点东西,但不专业;装 Skills 才能在特定领域表现专业
|
||||
- 理解 **插件机制**:插件是 Skills 的容器,一个插件可以包含多个 Skills(game-studio 包含 9 个 Skills)
|
||||
- 理解 **国产模型 ≠ 一击必胜**:即便装了 Skills,代码也需要审查迭代——这是下节课引入 **Subagent(自动审核员)** 的原因
|
||||
- 预先感知 **Subagent 概念 = 独立上下文窗口**:Subagent 等于另开一个会话,不污染主对话——下节课正式深入
|
||||
|
||||
**能力目标:**
|
||||
- 能在 QonnwolfCode/Trae 里**安装 game-studio 插件**(操作能力)
|
||||
- 能用自然语言**让 AI 调用 `phaser-2d-game` 或 `three-webgl-game` skill** 做出可玩的小游戏(提问力)
|
||||
- 能**对比"装 vs 不装"差异**,亲眼判断 Skills 的价值(韧性力)
|
||||
- 能基于 AI 生成的代码做**个性化定制**:改背景色、改判定阈值、加音效(提问力)
|
||||
|
||||
**情感目标:**
|
||||
- 体验"我装了一个食谱,AI 就突然会做这道菜"的爽点
|
||||
- 建立"**AI 不是万能,但通过 Skills 可以变得专业**"的正确认知
|
||||
- 感受到 90 分钟从 0 到一个能玩的 3D 跳一跳的成就感
|
||||
- 对"AI 工程化"产生兴趣——课程后续将打开更大的世界
|
||||
|
||||
---
|
||||
|
||||
### 2. 核心概念与误概念预设
|
||||
|
||||
**核心概念认知层级:**
|
||||
|
||||
| 概念 | 学生类比 | 认知层级 |
|
||||
|------|---------|---------|
|
||||
| Skill(技能/食谱) | 一本"做某菜的食谱"——AI 是厨师,Skill 是给厨师的专业菜谱 | 理解层 |
|
||||
| 插件(Plugin) | 食谱本——一本书里有 9 个不同的食谱(game-studio 有 9 个 skill) | 识别层 |
|
||||
| 装插件 | 厨师拿到一本新食谱——立刻知道怎么做这一类菜 | 应用层 |
|
||||
| 通用 AI vs 装 Skill 的 AI | 普通厨师(会做一点点)vs 专业大厨(精通这道菜) | 理解层 |
|
||||
| three-webgl-game skill | 专门教 AI 怎么用 Three.js 做 3D 游戏的"教材" | 识别层 |
|
||||
|
||||
**典型误概念表:**
|
||||
|
||||
| 编号 | 误概念 | 正确认知 | 激发策略 |
|
||||
|------|--------|---------|---------|
|
||||
| M1 | "AI 本来什么都会,Skills 没什么用" | 通用 AI 知识广但不精;Skills 让它在某领域突然变专业 | 演示:不装 game-studio 让 AI 做 3D 跳一跳 → 翻车;装上 → 一次成功 |
|
||||
| M2 | "Skills 是网上下载的代码,装上就能跑" | Skills 不是可执行代码,是给 AI 的"操作手册"——AI 读完后按手册写出符合规范的代码 | 让学生打开 game-studio 的 SKILL.md 看看,里面全是文字说明而非代码 |
|
||||
| M3 | "Skills 越多越好,全装上" | 装太多 skill 反而互相干扰;要按任务装合适的 | 类比:厨师不会同时用川菜 + 法餐 + 寿司食谱做同一道菜 |
|
||||
| M4 | "AI 用 Skill 写出来的代码,我都看不懂就直接用" | 即便 AI 写得对,学生也要能定制(改颜色、改音效、改阈值)——这是产品设计师的核心能力 | 课程末尾要求学生改至少 3 处:背景色、PERFECT 阈值、音效音调 |
|
||||
| M5 | "Skills 只能用在游戏" | game-studio 只是众多插件之一;Skills 可以覆盖任何专业领域(文案/数据/设计...) | 课末预告:下节课你会看到完全不同的 Skill |
|
||||
|
||||
---
|
||||
|
||||
### 3. 教学准备
|
||||
|
||||
**工具与环境:**
|
||||
- QonnwolfCode CLI(或 Trae IDE,已预装 game-studio 插件可用版本)
|
||||
- Node.js 已装(Phaser/Three.js CDN 模式不强依赖,但有 Node 便于本地服务器)
|
||||
- 浏览器:Chrome 最新版
|
||||
- 老师机器:**game-studio 已装好**(用作演示对比)
|
||||
- 学生机器:**game-studio 默认未装**(让学生自己装,体验"装上的瞬间")
|
||||
|
||||
**教学资源:**
|
||||
- 老师准备:
|
||||
- ✅ **老师备课包**:`prototype/跳一跳-3d/index.html`(可玩 3D 跳一跳成品,作为最终展示)
|
||||
- ✅ **失败演示资源**:不装 game-studio 时让 AI 写跳一跳的烂代码截图(可课前录好)
|
||||
- ✅ **简化保底版本**:如果学生卡壳,提供一个"半成品"HTML 让学生只做定制
|
||||
- ✅ **音效音调对照表**:打印一张"哪些频率好听"的小卡片
|
||||
- 学生资源:
|
||||
- 涂鸦PK 第10课的 Web Audio 经验(回收)
|
||||
- 之前做过的 Phaser CDN 经验(回收,虽然这次用 Three.js)
|
||||
|
||||
**教师备课体验任务**(老师必做):
|
||||
> 备课前,老师必须亲自完成:
|
||||
> 1. **在干净环境里走一遍**:先卸载 game-studio,让主 AI 写跳一跳,**亲眼看到翻车样子**(录屏或截图准备好失败案例)
|
||||
> 2. **装上 game-studio 走一遍**:让 AI 用 `three-webgl-game` skill 重做,确认可以一次成功(否则课堂会翻车)
|
||||
> 3. **预先想好"学生卡壳的 3 个点"**:
|
||||
> - 装插件命令记不住 → 准备命令小卡片
|
||||
> - AI 输出长代码学生看不懂 → 准备"只关注 background-color"的引导
|
||||
> - 定制时不知道改哪一行 → 在代码里加 `// TODO: 这里可以改你喜欢的颜色` 注释
|
||||
|
||||
---
|
||||
|
||||
### 4. 教学流程
|
||||
|
||||
**第一幕:联系 (Connect) — 10分钟** 🔗
|
||||
|
||||
**【环节】上节课回顾 + 课前热身 (3分钟)**
|
||||
|
||||
**师:** 各位探险队员,涂鸦PK 锦标赛你们做的角色我记得很深——能用一句话说说你最得意的设计吗?
|
||||
**生:** [预设回应 A] 我做了个全堆攻击的爆发型,一回合秒杀!
|
||||
**生:** [预设回应 B] 我做了个奇怪的逃跑型,赢了好几场!
|
||||
**【诊断点:学生是否能用"设计决策"语言**,而非功能列表表达】
|
||||
|
||||
**师:** 不错!所以你们已经会**从"我做了什么功能"上升到"我为什么这样设计"**——这是产品设计师的眼光。
|
||||
**师:** 今天我们要进入一个**全新的阶段**——AI 工程师的眼光。
|
||||
|
||||
**【环节】成品展示 + 抛问题 (7分钟)**
|
||||
|
||||
**师:** 我先给你们看一个东西——
|
||||
[**老师打开 `prototype/跳一跳-3d/index.html`**,屏幕上出现 3D 跳一跳]
|
||||
**师:** 这是一个 3D 跳一跳。来 [点名一个学生],你上来玩一下。
|
||||
[学生上台玩 1-2 分钟,老师在旁边解说:按住空格蓄力,松开跳跃,PERFECT 命中得 3 分]
|
||||
**师:** 大家觉得这个游戏怎么样?
|
||||
**生:** [预设 A] 挺好玩的!
|
||||
**生:** [预设 B] 比我之前做的那些还好看!
|
||||
**师:** 我告诉你们一个秘密——这个游戏,**90% 的代码是 AI 写的**,我只在旁边引导。
|
||||
**师:** 你们觉得这怎么做到的?为什么 AI 突然变得这么厉害了?
|
||||
**生:** [预设 A] AI 越来越聪明了?
|
||||
**生:** [预设 B] 老师用了什么我们不知道的 AI?
|
||||
**师:** 不是。我用的还是你们之前用的 AI。
|
||||
**师:** 但我给它**装了一个东西**——叫 **Skills**。
|
||||
**师:** 今天我们就来学:**Skills 是什么?为什么装上之后 AI 变厉害了?**
|
||||
|
||||
【**诊断点**:学生对"Skills"有没有先验认知】【认知层级:识别层】
|
||||
|
||||
**【分支 A】若学生立刻说"我知道,Skills 是技能!"**:
|
||||
**师:** 对,字面意思是技能。但**它的本质是什么?** AI 已经有技能了,为什么还要"装"?今天就是要回答这个。
|
||||
|
||||
**【分支 B】若学生沉默或好奇**:
|
||||
**师:** 好,带着这个好奇心往下听——你今天结束时就会变成专家,能给家长讲清楚。
|
||||
|
||||
---
|
||||
|
||||
**第二幕:建构 (Construct) — 65分钟** 🛠️
|
||||
|
||||
**【分段一:演示痛点 — 不装 game-studio 时 AI 翻车】(15分钟)**
|
||||
|
||||
**预设误概念:**
|
||||
- 误概念 M1:"AI 本来什么都会"
|
||||
- 误概念 M2:"Skills 是可下载的代码"
|
||||
|
||||
**讲解与演示 (Teach & Demo): (5分钟)**
|
||||
|
||||
**师:** 我先做一个**对照实验**给你们看。
|
||||
**师:** 现在,我**没有**装 game-studio 插件。这就是普通的 AI。
|
||||
**师:** 我让它做一个 3D 跳一跳——跟刚才那个一样的——看会发生什么。
|
||||
[老师打开 QonnwolfCode,确认 plugins 列表里**没有** game-studio]
|
||||
**师:** [输入] "用 Three.js 给我做一个 3D 跳一跳,小球从一个平台跳到另一个平台,有计分,有动画。"
|
||||
[AI 开始生成代码,过程中老师一边看一边解说]
|
||||
**师:** 你们看,AI 在写 Three.js 的代码——它知道 Three.js,这没错。
|
||||
**师:** 但是......
|
||||
|
||||
[**关键:把 AI 生成的代码运行,大概率会出现 bug:平台位置错乱、小球穿模、没有跳跃逻辑、画面平淡**]
|
||||
|
||||
**师:** 看,**代码跑起来了,但不像跳一跳**——小球乱飞,没有蓄力机制,画面一片混乱。
|
||||
**师:** 为什么?AI 不是知道 Three.js 吗?
|
||||
**生:** [预设 A] 也许它没理解清楚需求?
|
||||
**生:** [预设 B] 代码写得不够多?
|
||||
|
||||
【**诊断点**:学生能不能识别"AI 知道但不专业"的本质区别】【认知层级:理解层】
|
||||
|
||||
**师:** 都不是。问题在于:**AI 知道一点点 Three.js,但它不懂游戏开发的'专业套路'**——比如:
|
||||
**师:** 1. 小球跳跃要用什么样的抛物线?它不知道行业标准做法
|
||||
**师:** 2. 平台间距怎么设计才合理?它不知道游戏关卡的尺度
|
||||
**师:** 3. 蓄力机制要不要、怎么做?它没经验
|
||||
**师:** 4. PERFECT 判定的阈值多少才有挑战又不太难?它不知道
|
||||
**师:** **AI 像一个**会一点点烹饪基础**的人**,你让他做"宫保鸡丁",他知道大致流程,但做不出地道的味道。
|
||||
|
||||
**学生实践 (Practice): (8分钟)**
|
||||
|
||||
**师:** 现在你们试一次——让你们的 AI(也是没装插件的版本)做一个 2D 跳一跳。
|
||||
**师:** 看会发生什么。
|
||||
[学生在自己机器上输入需求,等待 AI 输出]
|
||||
[**老师走动观察**:谁的屏幕没动,谁打开代码不知道看什么]
|
||||
|
||||
**【可能的学生反应】**:
|
||||
- 反应 A:AI 输出代码,但学生不知道怎么跑 → 老师提示:复制到 HTML 文件,浏览器打开
|
||||
- 反应 B:代码有 bug 跑不起来 → 老师高兴地说:"对了!这就是今天要看到的现象——AI 不专业的样子"
|
||||
- 反应 C:勉强能跑但难玩 → 老师:"看,这就是问题"
|
||||
|
||||
**进度同步 (Checkpoint): (2分钟)**
|
||||
|
||||
**师:** 大家的 AI 做出来的跳一跳怎么样?好玩吗?
|
||||
**生:** [预设 A] 跑不起来!
|
||||
**生:** [预设 B] 跑起来了但很丑/很怪
|
||||
**生:** [预设 C] 我觉得还可以(少数自信的学生)
|
||||
|
||||
【**诊断点**:学生体感"通用 AI 不够专业"的认知有没有建立】
|
||||
|
||||
**师:** OK,我看大家的状态都很一致——**AI 不是不会,是不够专业**。
|
||||
**师:** 所以问题来了:**怎么让 AI 变专业?**
|
||||
|
||||
---
|
||||
|
||||
**【分段二:Skills 救场 — 装 game-studio 后 AI 变身】(15分钟)**
|
||||
|
||||
**预设误概念:**
|
||||
- 误概念 M1 二次激发:"AI 本来什么都会"
|
||||
- 误概念 M3:"Skills 越多越好"
|
||||
|
||||
**讲解与演示 (Teach & Demo): (8分钟)**
|
||||
|
||||
**师:** 现在我要给 AI **装一个东西**——这就是今天的关键词:**Skills**(技能 / 食谱)。
|
||||
**师:** 我要装的这个插件叫 **game-studio**——它里面有 9 个 Skills,专门教 AI 怎么做游戏。
|
||||
[**老师在 QonnwolfCode 里输入 `/plugin install game-studio`,确认安装成功**]
|
||||
**师:** 看,装好了。
|
||||
**师:** 现在,**同样的需求**——你们注意,我说的话和刚才**几乎一样**。
|
||||
[老师输入]:"用 Three.js 给我做一个 3D 跳一跳,小球从一个平台跳到另一个平台,有计分,有动画。"
|
||||
[AI 开始生成。这次:**速度更快,代码更整洁,直接调用 three-webgl-game skill 的标准模式**]
|
||||
|
||||
**师:** 你们看,AI 这次**还是 AI**——不是换了一个 AI——但它的输出**完全不同**。
|
||||
[运行结果:**漂亮的 3D 跳一跳**,蓄力 + 跳跃 + 计分都对]
|
||||
**师:** 这就是 Skills 的力量。
|
||||
**师:** 我**没有改 AI**,**没有改我的问题**——我**只装了一本'食谱'**,AI 就突然懂行了。
|
||||
|
||||
**师:** 来一个**类比**让你们彻底懂:
|
||||
**师:** AI 是一个**厨师**。
|
||||
**师:** Skill 是一本**食谱**。
|
||||
**师:** 厨师本来会做"大概的菜"——但拿到食谱,他立刻知道"这道菜的专业做法"。
|
||||
**师:** **AI = 厨师,Skill = 食谱,装 Skill = 给厨师菜谱**。
|
||||
|
||||
【**诊断点**:类比有没有让学生秒懂】【认知层级:理解层】
|
||||
|
||||
**师:** 谁能用自己的话再说一遍 Skill 是什么?
|
||||
**生:** [预设 A] Skill 就是一本书,AI 读完就会做了!
|
||||
**生:** [预设 B] Skill 是给 AI 装的"专业技能"!
|
||||
|
||||
**师:** 都对。但记住一个细节——**Skill 不是代码**,是**给 AI 看的操作手册**。
|
||||
[**老师打开 game-studio/skills/three-webgl-game/SKILL.md**,展示给学生看]
|
||||
**师:** 你们看,这里面全是**文字说明**——"3D 游戏架构应该怎么分层"、"相机怎么放"、"性能怎么优化"。**它不会自己运行**,但 AI 看完后,**写出来的代码就符合这些规范**。
|
||||
|
||||
**学生实践 (Practice): (5分钟)**
|
||||
|
||||
**师:** 现在你们也来装 game-studio。
|
||||
**师:** 命令是:`/plugin install game-studio`(老师在大屏幕上贴出来)
|
||||
[学生跟着输入]
|
||||
|
||||
**进度同步 (Checkpoint): (2分钟)**
|
||||
|
||||
**师:** 装好的同学举手?
|
||||
**师:** 装好的同学,**让你的 AI 重做一次跳一跳**——看看跟刚才"没装"时有什么不同。
|
||||
**生:** [预设 A] 哇!这次代码看着干净多了!
|
||||
**生:** [预设 B] 跑起来了!但还差点东西
|
||||
**生:** [预设 C] 我的还是不太行(老师过去帮忙)
|
||||
|
||||
【**诊断点**:学生有没有亲眼看到"装 vs 不装"的差异】
|
||||
|
||||
---
|
||||
|
||||
**【分段三:学生跑通跳一跳完整版】(15分钟)**
|
||||
|
||||
**预设误概念:**
|
||||
- 误概念 M4:"AI 写出来的代码看不懂就直接用"
|
||||
|
||||
**讲解与演示 (Teach & Demo): (3分钟)**
|
||||
|
||||
**师:** OK 现在大家都装了 game-studio,**AI 也写出了基础版**。
|
||||
**师:** 但跟我开头展示的那个**完整版**比,你们的还差**几个关键点**:
|
||||
**师:** 1. **蓄力机制**(按住空格才能蓄力,松开跳跃)——不只是按一下跳一下
|
||||
**师:** 2. **PERFECT 命中奖励**(精准落到平台中心 +3 分)
|
||||
**师:** 3. **音效**(跳跃声、落地声、PERFECT 声)
|
||||
**师:** 4. **小球的动画**(被压扁、旋转)
|
||||
|
||||
**师:** 这些都是**让游戏好玩的关键**——不是"功能",是"手感"。
|
||||
**师:** 怎么让 AI 加上?**用你的提问力**。
|
||||
**师:** 我给你们一个模板:
|
||||
```
|
||||
请帮我在这个跳一跳基础上加:
|
||||
1. 蓄力机制:按住空格才蓄力,松开跳。蓄力越久跳越远
|
||||
2. PERFECT 命中:落地距离中心 < 0.15 时,显示"+3 PERFECT!"
|
||||
3. 音效:跳/落地/PERFECT 各有一个声音(用 Web Audio API)
|
||||
4. 动画:小球蓄力时压扁,跳跃时旋转
|
||||
```
|
||||
|
||||
**学生实践 (Practice): (10分钟)**
|
||||
|
||||
**师:** 把这个模板**改成你的话**(可以加更多功能、可以减),发给 AI,让它加。
|
||||
**师:** 一定要**自己测试**——跑起来不对就告诉 AI 哪里不对。
|
||||
[**学生自由迭代**;老师走动]
|
||||
|
||||
**【可能的学生反应】**:
|
||||
- **反应 A**:AI 加上了所有功能但跑不起来 → 老师:"看 console,贴给 AI 让它修"
|
||||
- **反应 B**:学生加了一堆功能但忘了说"基础上"导致 AI 重写整个游戏 → 老师:"提醒 AI'在原代码基础上修改,不要重写'"
|
||||
- **反应 C**:学生想加自己的功能(比如换主角形状)→ 老师:"很好!那就是下节课要讲的'个性化定制'"
|
||||
|
||||
**进度同步 (Checkpoint): (2分钟)**
|
||||
|
||||
**师:** 谁来分享一下,你的跳一跳跟我的有什么不同?
|
||||
**生:** [预设 A] 我的小球是粉色的!
|
||||
**生:** [预设 B] 我加了一个"跳到一半响一下"的音效!
|
||||
**生:** [预设 C] 我的 PERFECT 阈值改成 0.3 了,更容易得 PERFECT!
|
||||
|
||||
【**诊断点**:学生开始有"自己的版本"的意识】【认知层级:应用层】
|
||||
|
||||
---
|
||||
|
||||
**【分段四:个性化定制 — 改 3 处】(20分钟)**
|
||||
|
||||
**预设误概念:**
|
||||
- 误概念 M4:"AI 写出来的代码看不懂就直接用"
|
||||
- 误概念 M5:"Skills 只能用在游戏"
|
||||
|
||||
**讲解与演示 (Teach & Demo): (3分钟)**
|
||||
|
||||
**师:** 现在大家都有了一个自己的"跳一跳基础版"。
|
||||
**师:** 但**真正的产品设计师不会满足于'AI 给我的'**——他们会**改成自己的样子**。
|
||||
**师:** 你们要改 3 处:
|
||||
**师:** 1. **背景颜色**(浅蓝太老套了,改成你喜欢的)
|
||||
**师:** 2. **PERFECT 阈值**(0.15 太严,改成 0.3 让自己容易得 PERFECT;或者改成 0.05 让自己玩得更挑战)
|
||||
**师:** 3. **至少一个音效的频率**(让"跳"的声音不一样)
|
||||
|
||||
**师:** 重点:**不是让 AI 改**,是**你自己改代码**——这样你才知道"代码到底在做什么"。
|
||||
**师:** 我给你们一个**找代码的方法**:
|
||||
**师:** - 想改背景色?在代码里搜 `background` 或 `color`
|
||||
**师:** - 想改 PERFECT 阈值?搜 `0.15` 或 `perfect`
|
||||
**师:** - 想改音效频率?搜 `playTone` 或 `frequency`
|
||||
|
||||
**学生实践 (Practice): (15分钟)**
|
||||
|
||||
[**学生独立改 3 处**;老师走动观察]
|
||||
|
||||
**【可能的学生反应】**:
|
||||
- **反应 A**:改了一下跑不起来 → 老师:"你改完了再保存重新打开浏览器"
|
||||
- **反应 B**:不知道哪一行是背景色 → 老师指着代码:"看,这一行 `background-color: ...`"
|
||||
- **反应 C**:学生想改更复杂的东西(比如平台形状)→ 老师:"很好!**那就是你的进阶——AI 写的代码你能改的越来越多**"
|
||||
|
||||
**进度同步 (Checkpoint): (2分钟)**
|
||||
|
||||
**师:** 大家改成你自己的样子了吗?分享一下最得意的改动?
|
||||
**生:** [预设 A] 我把背景改成深紫色,小球改成绿色,看起来像在外太空!
|
||||
**生:** [预设 B] 我把 PERFECT 阈值改到 0.5,现在我每跳都是 PERFECT!
|
||||
**生:** [预设 C] 我的"跳"声音改成超高频,像鸟叫!
|
||||
|
||||
【**诊断点**:学生是不是从"用 AI 给的"转变成"做自己的"】【认知层级:应用层 → 迁移层】
|
||||
|
||||
---
|
||||
|
||||
**第三幕:反思 (Contemplate) — 8分钟** 🤔
|
||||
|
||||
**【环节】成果展示 + 互玩 (5分钟)**
|
||||
|
||||
**师:** 现在 2 个最大胆的同学上来——展示你们的跳一跳!不只是玩,**还要讲你改了什么、为什么这么改**。
|
||||
[2-3 名学生上台]
|
||||
**师:** [展示完后] 现在 3 分钟自由互玩——**比谁分高**!
|
||||
[学生互相打开同桌的网页玩]
|
||||
|
||||
**【环节】元认知讨论 + Skills 工具箱类比 (3分钟)**
|
||||
|
||||
**师:** 大家今天做了一件事:**给 AI 装了一本食谱,然后让 AI 帮你做出游戏**。
|
||||
**师:** 这跟之前所有的"让 AI 写代码"有什么不一样?
|
||||
**生:** [预设 A] 之前我们让 AI 写,它经常出错;今天写得**准确**多了
|
||||
**生:** [预设 B] 今天感觉 AI 真的"懂"游戏了
|
||||
**生:** [预设 C] 之前都是从零开始,今天是"在 AI 给的基础上修"——效率高多了
|
||||
|
||||
【**诊断点**:学生有没有从"AI 是个工具"上升到"AI 是个可装备的合作者"】【认知层级:迁移层】
|
||||
|
||||
**师:** 我要再给你们一个**更准的类比**——之前我说 Skill 是"食谱",其实**更准确的说法**是:
|
||||
**师:** **Skill = 给 AI 装的"工具箱"**。
|
||||
**师:** 不是 AI 变聪明了——**是 AI 多了一些'专业工具'能用**。
|
||||
**师:** 比如:你装了"电锯 skill",你说"砍这棵树",AI 就**用电锯**;不装,它只能用**斧头**——一样能砍,但慢、不专业。
|
||||
**师:** **Skills 就是 AI 的'工具箱'**——你给它的工具越好,它做事越专业。
|
||||
**师:** 这一点非常重要:**学生不用自己造轮子**——AI 工程界已经有大量好用的 Skills,**你装上直接用就行**。
|
||||
**师:** 这是真实工程师的工作方式——**调用现成的好工具,不重新发明**。
|
||||
|
||||
---
|
||||
|
||||
**第四幕:延续 (Continue) — 7分钟** 🚀
|
||||
|
||||
**【环节】抽象总结 (1分钟)**
|
||||
|
||||
**师:** 今天用一句话总结:
|
||||
**师:** **"Skill = 给 AI 的工具箱。装上工具,AI 就在那个领域突然变专业了。"**
|
||||
|
||||
**【环节】抛出真问题 — 国产模型 ≠ 一击必胜 (2分钟)**
|
||||
|
||||
**师:** 我要给你们说一个**老师不告诉你们的小秘密**——
|
||||
**师:** 即便装了 Skills,**AI 写的代码也不是一次就完美**——
|
||||
**师:** **我们用的国产大模型(豆包、Kimi、文心、通义...)往往不能一击必胜**。需要审 → 改 → 再审 → 再改,可能要好几轮。
|
||||
**师:** 那问题来了——**谁来审?**
|
||||
**师:** 我之前的做法是这样的(老师做痛苦表情演示):
|
||||
**师:** "写完代码 → 复制 → 粘贴到**另一个窗口** → 让 AI 审 → 复制审核意见 → **粘回**主窗口 → 让 AI 改 → 又复制 → 又粘 → 又审..."
|
||||
**师:** 学生:听起来很累吧?
|
||||
**生:** [预设] 累!麻烦!
|
||||
|
||||
**【环节】Subagent 预告 — 自动化审核 (3分钟)**
|
||||
|
||||
**师:** 下节课的**核心新概念**:**Subagent(子智能体)**——专门解决这个痛点。
|
||||
**师:** 什么是 Subagent?用一句话:**它是 AI 召唤的另一个 AI 助手,有自己独立的窗口**。
|
||||
**师:** **关键原理**:Subagent 等于**开了一个新会话**——它**不污染你的主对话**,也**不被主对话的历史污染**。**完全独立的上下文窗口**。
|
||||
**师:** 这意味着什么?**它是一个干净的、专业的、独立的审核员**。
|
||||
**师:** **下节课你们会做这件事:**
|
||||
**师:** 1. 主 AI 帮你写代码
|
||||
**师:** 2. 你说一句"帮我召唤 Reviewer(审核员)审一下"
|
||||
**师:** 3. Subagent 自动审、自动给意见、自动改——**你不用复制粘贴了**
|
||||
**师:** 4. 直到没有问题为止
|
||||
**师:** **完全自动化的工作流**。
|
||||
|
||||
**【环节】未来路线图 — 三角色协作 (1分钟)**
|
||||
|
||||
**师:** 后续我们会建立 **3 个 AI 角色**,让它们协作完成你的塔防游戏:
|
||||
**师:** 🧑💼 **Planner(计划员)** — 负责写"PRD"(产品需求文档)
|
||||
**师:** 🔍 **Reviewer(审核员)** — 审计划、审代码,挑毛病
|
||||
**师:** 🧪 **Tester(测试员)** — 用 Playwright 自动测试你的游戏
|
||||
**师:** **你不写一行代码**——只发号施令。这 3 个 AI 自己协作做出你的塔防。
|
||||
**师:** 这是真正的 **AI 工程师工作流**——管理 AI 团队,而不是自己写代码。
|
||||
|
||||
**【环节】5 分钟挑战 (无新内容,直接发布)**
|
||||
|
||||
**师:** 本周的 **5 分钟 AI 挑战**:**给跳一跳加一个你最喜欢的功能**——
|
||||
**师:** - 加一个"BOSS 平台"(深红色,得 5 分)
|
||||
**师:** - 加一个"反向跳"功能(向左跳)
|
||||
**师:** - 加一个"双倍蓄力"模式(按 Shift+空格)
|
||||
**师:** 下周来上课时拿给大家看!
|
||||
|
||||
---
|
||||
|
||||
### 5. AI助教使用指南
|
||||
|
||||
**教师演示用提示词**:
|
||||
|
||||
```
|
||||
[不装 game-studio 版本]
|
||||
用 Three.js 给我做一个 3D 跳一跳,小球从一个平台跳到另一个平台,
|
||||
有计分,有动画。
|
||||
```
|
||||
|
||||
```
|
||||
[装 game-studio 后版本]
|
||||
用 three-webgl-game skill 做一个 3D 跳一跳:
|
||||
- 小球可以蓄力跳跃(按空格蓄力,松开跳)
|
||||
- 蓄力越久跳越远(蓄力时小球被压扁可视化)
|
||||
- 落到下一个平台 +1 分,中心 PERFECT +3 分
|
||||
- Web Audio 合成跳/落地/PERFECT 音效
|
||||
- 等距视角 + 阴影,卡通可爱风格
|
||||
```
|
||||
|
||||
**学生保底提示词**(如果学生想不出怎么说):
|
||||
|
||||
```
|
||||
请用 game-studio 的 three-webgl-game skill 帮我做一个 3D 跳一跳:
|
||||
1. 小球可以跳跃(按空格)
|
||||
2. 落到平台得分
|
||||
3. 落到地上 GAME OVER
|
||||
其他细节你看着办。
|
||||
```
|
||||
|
||||
**进阶提示词**(学有余力):
|
||||
|
||||
```
|
||||
在我的跳一跳基础上,加一个"双人对战"模式:
|
||||
- 左边玩家用 A/D 控制,右边玩家用 ←/→
|
||||
- 各有自己的平台序列
|
||||
- 谁先得 20 分谁赢
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. 教师指南
|
||||
|
||||
**本课技术备注**:
|
||||
- game-studio 插件实际是 9 个 SKILL.md 文件 + 部分 Python 脚本的集合
|
||||
- 当前用到的关键 skill:
|
||||
- `three-webgl-game` — 教 AI 用 Three.js 做 3D 游戏(本课主要用)
|
||||
- `phaser-2d-game` — 教 AI 用 Phaser 做 2D 游戏(如果学生选 2D 跳一跳)
|
||||
- `game-playtest` — 自动化测试(第 15 课会用)
|
||||
- Three.js 通过 CDN 加载,**不需要 npm install**——这是教学场景的关键(零环境配置)
|
||||
|
||||
**常见问题 FAQ**:
|
||||
|
||||
| 问题 | 应对 |
|
||||
|------|------|
|
||||
| 学生没装 Node.js 怎么办? | Three.js CDN + 单文件 HTML 不需要 Node;只是用 `python3 -m http.server` 起本地服务器即可 |
|
||||
| AI 写的代码学生看不懂? | 不要求学生全懂——只要求改 3 个具体地方(背景色/PERFECT 阈值/音效)就行 |
|
||||
| 学生改完代码报错? | 浏览器按 F12 看 console,把错误贴给 AI,AI 一般能修 |
|
||||
| 学生抱怨 game-studio 装不上? | 备用方案:用老师机器演示,学生直接拿到老师写的 `index.html` 改定制 |
|
||||
| 学生改完发现"装 game-studio 没看出来差别"? | 让 ta 真的对比:**先开"未装版"AI 的代码(老师备好的截图)**vs 自己装上后的版本,差异一目了然 |
|
||||
|
||||
**课堂风险预案**:
|
||||
- 如果 game-studio 服务不可用 → 老师准备好"装完成"的截图 + 完整代码,直接发给学生改定制(跳过"装"步骤,聚焦"个性化"环节)
|
||||
- 如果学生进度差异过大 → 已完成的学生帮卡壳的学生(同伴互助)
|
||||
- 如果时间不够 → 砍掉"个性化定制 3 处"(分段四),只完成"装 + 跑出基础版"
|
||||
|
||||
---
|
||||
|
||||
### 7. 5分钟日常AI挑战
|
||||
|
||||
**本周挑战**:**给跳一跳加一个你最喜欢的功能**
|
||||
|
||||
**挑战说明**:
|
||||
- 可以是 BOSS 平台(深红色,得 5 分)
|
||||
- 可以是反向跳(向左跳)
|
||||
- 可以是双倍蓄力(按 Shift+空格)
|
||||
- 可以是连击奖励(连续 3 个 PERFECT 给"双倍下一关")
|
||||
- **要求**:能跑、能看到、能讲出"我为什么这么设计"
|
||||
|
||||
**下节课分享**:下周课上选 2-3 位同学展示自己的"个性化跳一跳"
|
||||
|
||||
---
|
||||
|
||||
### 8. 拓展任务
|
||||
|
||||
**拓展一(推荐)**:
|
||||
- **PERFECT 阈值动态化**——分数越高,PERFECT 阈值越严(0.15 → 0.12 → 0.10)
|
||||
- 让 game-studio 帮你写"难度递增"逻辑
|
||||
|
||||
**拓展二(挑战)**:
|
||||
- **触屏支持**——在手机/平板上能玩
|
||||
- 让 AI 加 `touchstart` / `touchend` 事件
|
||||
- 拿手机扫码玩自己的跳一跳
|
||||
|
||||
**拓展三(超挑战)**:
|
||||
- **多 Skill 组合**——同时装 `game-playtest` skill,让 AI 自动测试你的跳一跳
|
||||
- 体验"Skills 组合拳"
|
||||
|
||||
---
|
||||
|
||||
## 教学课后笔记(老师课后填写)
|
||||
|
||||
- 学生对"Skills"概念的接受度:?/10
|
||||
- "装 vs 不装"演示的效果:?/10
|
||||
- 学生个性化定制完成率:__%
|
||||
- 下次改进点:_________
|
||||
BIN
hero-single.png
Normal file
BIN
hero-single.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
BIN
hero-spritesheet.png
Normal file
BIN
hero-spritesheet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
1317
prototype/单词塔防/game-3d.html
Normal file
1317
prototype/单词塔防/game-3d.html
Normal file
File diff suppressed because it is too large
Load Diff
1010
prototype/单词塔防/index.html
Normal file
1010
prototype/单词塔防/index.html
Normal file
File diff suppressed because it is too large
Load Diff
700
prototype/单词塔防/level-editor-3d.html
Normal file
700
prototype/单词塔防/level-editor-3d.html
Normal file
@@ -0,0 +1,700 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>🗺️ 单词塔防 · 3D 关卡设计器</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: #0d1b2a;
|
||||
color: #eee;
|
||||
font-family: -apple-system, "PingFang SC", sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
#header {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
background: rgba(13, 27, 42, 0.92);
|
||||
z-index: 10;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #ffd700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#header h1 {
|
||||
color: #ffd700;
|
||||
font-size: 16px;
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
}
|
||||
#header input {
|
||||
padding: 6px 10px;
|
||||
background: #1a1a2e;
|
||||
color: #ffd700;
|
||||
border: 1px solid #ffd700;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
width: 180px;
|
||||
}
|
||||
#header button {
|
||||
padding: 6px 12px;
|
||||
background: linear-gradient(180deg, #4a90e2 0%, #357abd 100%);
|
||||
color: white;
|
||||
border: 2px solid #2a5a8e;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
font-family: inherit;
|
||||
}
|
||||
#header button:hover { filter: brightness(1.2); }
|
||||
#header button.primary {
|
||||
background: linear-gradient(180deg, #ff7a00 0%, #cc5500 100%);
|
||||
border-color: #ff9900;
|
||||
}
|
||||
|
||||
#palette {
|
||||
position: fixed;
|
||||
left: 0; top: 50px;
|
||||
bottom: 0;
|
||||
width: 200px;
|
||||
background: rgba(13, 27, 42, 0.88);
|
||||
border-right: 1px solid #444;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
z-index: 5;
|
||||
}
|
||||
#palette h3 {
|
||||
color: #ffd700;
|
||||
font-size: 12px;
|
||||
margin: 8px 0 4px;
|
||||
padding-bottom: 2px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
#palette h3:first-child { margin-top: 0; }
|
||||
#palette .ptile {
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 3px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #eee;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
#palette .ptile:hover {
|
||||
background: rgba(255, 215, 0, 0.15);
|
||||
border-color: #ffd700;
|
||||
}
|
||||
#palette .ptile.selected {
|
||||
background: linear-gradient(180deg, #ff7a00 0%, #cc5500 100%);
|
||||
border-color: #ffd700;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#right-panel {
|
||||
position: fixed;
|
||||
right: 0; top: 50px;
|
||||
bottom: 0;
|
||||
width: 200px;
|
||||
background: rgba(13, 27, 42, 0.88);
|
||||
border-left: 1px solid #444;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
z-index: 5;
|
||||
}
|
||||
#right-panel h3 {
|
||||
color: #ffd700;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.saved-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 3px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.saved-item .name { color: #ffd700; flex: 1; cursor: pointer; }
|
||||
.saved-item .name:hover { color: #fff; }
|
||||
.saved-item .action {
|
||||
background: rgba(255, 100, 100, 0.2);
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 1px 6px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
#scene-container {
|
||||
position: absolute;
|
||||
left: 200px;
|
||||
right: 200px;
|
||||
top: 50px;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
#status {
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
left: 210px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #ffd700;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
#loading {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffd700;
|
||||
font-size: 22px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
right: 220px;
|
||||
background: #4a8a4a;
|
||||
color: #fff;
|
||||
padding: 10px 18px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 20px rgba(0, 255, 0, 0.4);
|
||||
z-index: 200;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.3s;
|
||||
font-size: 13px;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
.toast.error { background: #a52a2a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="loading">⏳ 加载 Three.js 与 3D 模型...</div>
|
||||
|
||||
<div id="header">
|
||||
<h1>🗺️ 3D 关卡设计器</h1>
|
||||
<input type="text" id="level-name" placeholder="地图名字...">
|
||||
<button class="primary" id="btn-save">💾 保存</button>
|
||||
<button id="btn-new">📄 新建</button>
|
||||
<button id="btn-clear">🗑️ 清空</button>
|
||||
<button id="btn-export">📤 导出 JSON</button>
|
||||
<button id="btn-rotate">🔄 旋转选中(R)</button>
|
||||
<button id="btn-delete">❌ 删除模式(X)</button>
|
||||
</div>
|
||||
|
||||
<div id="palette"></div>
|
||||
<div id="scene-container"></div>
|
||||
<div id="right-panel">
|
||||
<h3>📚 已保存地图</h3>
|
||||
<div id="saved-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="status">点 palette 选 tile → 点地面放置 · 拖动右键转视角 · 滚轮缩放</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
// ============================================================
|
||||
// 音效系统 — Web Audio API 纯代码合成,零文件依赖
|
||||
// ============================================================
|
||||
let audioCtx = null;
|
||||
function ensureAudio() {
|
||||
if (!audioCtx) {
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
if (audioCtx.state === 'suspended') audioCtx.resume();
|
||||
}
|
||||
|
||||
function playTone(freq, duration, type = 'sine', volume = 0.15) {
|
||||
ensureAudio();
|
||||
const osc = audioCtx.createOscillator();
|
||||
const gain = audioCtx.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.value = freq;
|
||||
osc.connect(gain);
|
||||
gain.connect(audioCtx.destination);
|
||||
const t = audioCtx.currentTime;
|
||||
gain.gain.setValueAtTime(volume, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + duration);
|
||||
osc.start(t);
|
||||
osc.stop(t + duration);
|
||||
}
|
||||
|
||||
// 各种音效配方
|
||||
const SFX = {
|
||||
place: () => { playTone(523, 0.06, 'triangle', 0.12); setTimeout(() => playTone(659, 0.08, 'triangle', 0.10), 30); },
|
||||
erase: () => { playTone(220, 0.15, 'sawtooth', 0.12); },
|
||||
rotate: () => { playTone(880, 0.05, 'triangle', 0.08); },
|
||||
hover: () => { playTone(1200, 0.02, 'sine', 0.04); },
|
||||
select: () => { playTone(440, 0.06, 'sine', 0.10); setTimeout(() => playTone(660, 0.08, 'sine', 0.08), 40); },
|
||||
save: () => {
|
||||
playTone(523, 0.1, 'sine', 0.15);
|
||||
setTimeout(() => playTone(659, 0.1, 'sine', 0.15), 80);
|
||||
setTimeout(() => playTone(784, 0.18, 'sine', 0.15), 160);
|
||||
},
|
||||
load: () => { playTone(659, 0.1, 'sine', 0.12); setTimeout(() => playTone(880, 0.15, 'sine', 0.12), 80); },
|
||||
error: () => { playTone(200, 0.2, 'sawtooth', 0.15); setTimeout(() => playTone(150, 0.25, 'sawtooth', 0.15), 100); },
|
||||
clear: () => { playTone(330, 0.08, 'square', 0.10); setTimeout(() => playTone(220, 0.15, 'square', 0.10), 60); },
|
||||
modeToggle: () => { playTone(700, 0.05, 'square', 0.08); setTimeout(() => playTone(900, 0.05, 'square', 0.08), 30); },
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 配置
|
||||
// ============================================================
|
||||
const MODEL_BASE = 'assets/kenney-td-3d/Models/GLB%20format/';
|
||||
const TILE_SIZE = 1.0; // 每个网格单元的世界尺寸
|
||||
const GRID_SIZE = 12; // 12x12 网格
|
||||
|
||||
// Palette — 分类显示的 tile/塔/装饰
|
||||
const PALETTE = {
|
||||
'🌿 基础地形': [
|
||||
{ key: 'straight', file: 'tile.glb', label: '空地草地' },
|
||||
{ key: 'dirt', file: 'tile-dirt.glb', label: '泥土' },
|
||||
{ key: 'tile-straight', file: 'tile-straight.glb', label: '路径(直)' },
|
||||
{ key: 'tile-corner-square',file: 'tile-corner-square.glb', label: '路径(弯)' },
|
||||
{ key: 'tile-crossing', file: 'tile-crossing.glb', label: '路径(十字)' },
|
||||
{ key: 'tile-split', file: 'tile-split.glb', label: '路径(T 叉)' },
|
||||
],
|
||||
'🚪 起点 / 终点': [
|
||||
{ key: 'tile-spawn', file: 'tile-spawn.glb', label: '🚪 起点 spawn' },
|
||||
{ key: 'tile-spawn-end', file: 'tile-spawn-end.glb', label: '🏰 终点' },
|
||||
],
|
||||
'❄️ 雪地变体': [
|
||||
{ key: 'snow-tile', file: 'snow-tile.glb', label: '雪地' },
|
||||
{ key: 'snow-tile-straight',file: 'snow-tile-straight.glb', label: '雪路(直)' },
|
||||
{ key: 'snow-tile-corner', file: 'snow-tile-corner-square.glb', label: '雪路(弯)' },
|
||||
],
|
||||
'🗼 塔(完整)': [
|
||||
{ key: 'tower-round', file: 'tower-round-bottom-a.glb', label: '🏛️ 圆塔底' },
|
||||
{ key: 'tower-square', file: 'tower-square-bottom-a.glb', label: '⬜ 方塔底' },
|
||||
{ key: 'tower-crystals', file: 'tower-round-crystals.glb', label: '💎 水晶塔' },
|
||||
],
|
||||
'🌳 装饰': [
|
||||
{ key: 'tile-tree', file: 'tile-tree.glb', label: '🌲 树' },
|
||||
{ key: 'tile-tree-double', file: 'tile-tree-double.glb', label: '🌲🌲 双树' },
|
||||
{ key: 'tile-rock', file: 'tile-rock.glb', label: '🪨 岩石' },
|
||||
{ key: 'tile-crystal', file: 'tile-crystal.glb', label: '💎 水晶' },
|
||||
{ key: 'detail-tree', file: 'detail-tree.glb', label: '🌳 小树' },
|
||||
],
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Three.js 场景
|
||||
// ============================================================
|
||||
const container = document.getElementById('scene-container');
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x87ceeb);
|
||||
scene.fog = new THREE.Fog(0x87ceeb, 20, 50);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
45,
|
||||
container.clientWidth / container.clientHeight,
|
||||
0.1, 100
|
||||
);
|
||||
camera.position.set(10, 12, 10);
|
||||
camera.lookAt(GRID_SIZE/2, 0, GRID_SIZE/2);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.target.set(GRID_SIZE/2, 0, GRID_SIZE/2);
|
||||
controls.enablePan = true;
|
||||
controls.minDistance = 5;
|
||||
controls.maxDistance = 30;
|
||||
controls.maxPolarAngle = Math.PI / 2 - 0.05;
|
||||
|
||||
// 光照
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 0.9);
|
||||
sun.position.set(10, 20, 5);
|
||||
sun.castShadow = true;
|
||||
sun.shadow.mapSize.set(2048, 2048);
|
||||
sun.shadow.camera.left = -20;
|
||||
sun.shadow.camera.right = 20;
|
||||
sun.shadow.camera.top = 20;
|
||||
sun.shadow.camera.bottom = -20;
|
||||
scene.add(sun);
|
||||
|
||||
// 地面网格(辅助参考)
|
||||
const gridHelper = new THREE.GridHelper(GRID_SIZE, GRID_SIZE, 0xaaaaaa, 0x666666);
|
||||
gridHelper.position.set(GRID_SIZE/2, 0.01, GRID_SIZE/2);
|
||||
scene.add(gridHelper);
|
||||
|
||||
// 透明地面(供 raycast 检测点击)
|
||||
const groundGeo = new THREE.PlaneGeometry(GRID_SIZE, GRID_SIZE);
|
||||
const groundMat = new THREE.MeshLambertMaterial({ color: 0x4a8a4a });
|
||||
const ground = new THREE.Mesh(groundGeo, groundMat);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.position.set(GRID_SIZE/2, 0, GRID_SIZE/2);
|
||||
ground.receiveShadow = true;
|
||||
ground.userData.isGround = true;
|
||||
scene.add(ground);
|
||||
|
||||
// 选中高亮框(在鼠标 hover 的格子上)
|
||||
const hoverGeo = new THREE.BoxGeometry(TILE_SIZE, 0.1, TILE_SIZE);
|
||||
const hoverMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xffd700, transparent: true, opacity: 0.4
|
||||
});
|
||||
const hoverBox = new THREE.Mesh(hoverGeo, hoverMat);
|
||||
hoverBox.visible = false;
|
||||
scene.add(hoverBox);
|
||||
|
||||
// ============================================================
|
||||
// 模型加载与缓存
|
||||
// ============================================================
|
||||
const loader = new GLTFLoader();
|
||||
const modelCache = new Map();
|
||||
|
||||
async function loadModel(file) {
|
||||
if (modelCache.has(file)) return modelCache.get(file).clone();
|
||||
return new Promise((resolve, reject) => {
|
||||
loader.load(MODEL_BASE + encodeURIComponent(file), (gltf) => {
|
||||
const obj = gltf.scene;
|
||||
obj.traverse((node) => {
|
||||
if (node.isMesh) {
|
||||
node.castShadow = true;
|
||||
node.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
modelCache.set(file, obj);
|
||||
resolve(obj.clone());
|
||||
}, undefined, reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 编辑器状态
|
||||
// ============================================================
|
||||
let selectedTile = null; // PALETTE 里的一个 item
|
||||
let placedTiles = new Map(); // gridKey "x,z" → { item, mesh, rotY }
|
||||
let isDeleteMode = false;
|
||||
let currentRotation = 0; // 当前旋转(弧度,0/π/2/π/3π/2)
|
||||
|
||||
function gridKey(x, z) { return `${x},${z}`; }
|
||||
|
||||
async function placeTile(gx, gz) {
|
||||
if (!selectedTile) return;
|
||||
const key = gridKey(gx, gz);
|
||||
// 先移除已有
|
||||
if (placedTiles.has(key)) {
|
||||
scene.remove(placedTiles.get(key).mesh);
|
||||
placedTiles.delete(key);
|
||||
}
|
||||
const model = await loadModel(selectedTile.file);
|
||||
model.position.set(gx + 0.5, 0, gz + 0.5);
|
||||
model.rotation.y = currentRotation;
|
||||
scene.add(model);
|
||||
placedTiles.set(key, {
|
||||
item: selectedTile,
|
||||
mesh: model,
|
||||
rotY: currentRotation,
|
||||
file: selectedTile.file,
|
||||
key: selectedTile.key,
|
||||
});
|
||||
SFX.place();
|
||||
}
|
||||
|
||||
function eraseTile(gx, gz) {
|
||||
const key = gridKey(gx, gz);
|
||||
if (placedTiles.has(key)) {
|
||||
scene.remove(placedTiles.get(key).mesh);
|
||||
placedTiles.delete(key);
|
||||
SFX.erase();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 鼠标交互(raycast)
|
||||
// ============================================================
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const mouse = new THREE.Vector2();
|
||||
|
||||
function getGridFromMouse(event) {
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const hits = raycaster.intersectObject(ground);
|
||||
if (hits.length === 0) return null;
|
||||
const p = hits[0].point;
|
||||
const gx = Math.floor(p.x);
|
||||
const gz = Math.floor(p.z);
|
||||
if (gx < 0 || gx >= GRID_SIZE || gz < 0 || gz >= GRID_SIZE) return null;
|
||||
return { gx, gz };
|
||||
}
|
||||
|
||||
renderer.domElement.addEventListener('mousemove', (e) => {
|
||||
const g = getGridFromMouse(e);
|
||||
if (g) {
|
||||
hoverBox.position.set(g.gx + 0.5, 0.05, g.gz + 0.5);
|
||||
hoverBox.visible = true;
|
||||
hoverMat.color.setHex(isDeleteMode ? 0xff3333 : 0xffd700);
|
||||
} else {
|
||||
hoverBox.visible = false;
|
||||
}
|
||||
});
|
||||
|
||||
renderer.domElement.addEventListener('click', async (e) => {
|
||||
if (e.button !== 0) return;
|
||||
const g = getGridFromMouse(e);
|
||||
if (!g) return;
|
||||
if (isDeleteMode) {
|
||||
eraseTile(g.gx, g.gz);
|
||||
} else if (selectedTile) {
|
||||
await placeTile(g.gx, g.gz);
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Palette UI
|
||||
// ============================================================
|
||||
function renderPalette() {
|
||||
const el = document.getElementById('palette');
|
||||
el.innerHTML = '';
|
||||
for (const [cat, items] of Object.entries(PALETTE)) {
|
||||
const h = document.createElement('h3');
|
||||
h.textContent = cat;
|
||||
el.appendChild(h);
|
||||
for (const item of items) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'ptile' + (selectedTile?.key === item.key ? ' selected' : '');
|
||||
div.textContent = item.label;
|
||||
div.addEventListener('click', () => {
|
||||
selectedTile = item;
|
||||
isDeleteMode = false;
|
||||
renderPalette();
|
||||
SFX.select();
|
||||
document.getElementById('status').textContent =
|
||||
`已选: ${item.label} · 点地面放置 · 按 R 旋转 · 按 X 切换删除模式`;
|
||||
});
|
||||
el.appendChild(div);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 键盘
|
||||
// ============================================================
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.target.tagName === 'INPUT') return;
|
||||
if (e.key === 'r' || e.key === 'R') {
|
||||
currentRotation = (currentRotation + Math.PI / 2) % (Math.PI * 2);
|
||||
SFX.rotate();
|
||||
toast(`已旋转: ${Math.round(currentRotation * 180 / Math.PI)}°`);
|
||||
}
|
||||
if (e.key === 'x' || e.key === 'X') {
|
||||
isDeleteMode = !isDeleteMode;
|
||||
SFX.modeToggle();
|
||||
document.getElementById('status').textContent =
|
||||
isDeleteMode ? '🗑️ 删除模式:点格子清除' : (selectedTile ? `已选: ${selectedTile.label}` : '请选 tile');
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 保存 / 加载
|
||||
// ============================================================
|
||||
function saveLevel() {
|
||||
const name = document.getElementById('level-name').value.trim();
|
||||
if (!name) { toast('请输入地图名字', true); return; }
|
||||
const data = {
|
||||
name,
|
||||
gridSize: GRID_SIZE,
|
||||
tiles: Array.from(placedTiles.entries()).map(([key, val]) => {
|
||||
const [x, z] = key.split(',').map(Number);
|
||||
return { x, z, key: val.key, file: val.file, rotY: val.rotY };
|
||||
}),
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
const all = loadAllLevels();
|
||||
all[name] = data;
|
||||
localStorage.setItem('wordTD-3d-levels', JSON.stringify(all));
|
||||
SFX.save();
|
||||
toast(`✅ 已保存「${name}」`);
|
||||
renderSavedList();
|
||||
}
|
||||
|
||||
function loadAllLevels() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('wordTD-3d-levels') || '{}');
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
async function loadLevel(name) {
|
||||
const all = loadAllLevels();
|
||||
const lvl = all[name];
|
||||
if (!lvl) return;
|
||||
// 清空当前
|
||||
for (const [, val] of placedTiles) scene.remove(val.mesh);
|
||||
placedTiles.clear();
|
||||
// 加载
|
||||
for (const t of lvl.tiles) {
|
||||
const model = await loadModel(t.file);
|
||||
model.position.set(t.x + 0.5, 0, t.z + 0.5);
|
||||
model.rotation.y = t.rotY || 0;
|
||||
scene.add(model);
|
||||
placedTiles.set(gridKey(t.x, t.z), {
|
||||
item: { key: t.key, file: t.file },
|
||||
mesh: model,
|
||||
rotY: t.rotY || 0,
|
||||
file: t.file,
|
||||
key: t.key,
|
||||
});
|
||||
}
|
||||
document.getElementById('level-name').value = lvl.name;
|
||||
SFX.load();
|
||||
toast(`📂 已加载「${name}」`);
|
||||
}
|
||||
|
||||
function deleteLevel(name) {
|
||||
if (!confirm(`删除「${name}」?`)) return;
|
||||
const all = loadAllLevels();
|
||||
delete all[name];
|
||||
localStorage.setItem('wordTD-3d-levels', JSON.stringify(all));
|
||||
renderSavedList();
|
||||
toast(`🗑️ 已删除`);
|
||||
}
|
||||
|
||||
function renderSavedList() {
|
||||
const el = document.getElementById('saved-list');
|
||||
const all = loadAllLevels();
|
||||
const names = Object.keys(all).sort();
|
||||
if (names.length === 0) {
|
||||
el.innerHTML = '<div style="color:#666;font-size:11px;text-align:center;padding:10px 0">还没保存的地图</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = '';
|
||||
for (const name of names) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'saved-item';
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'name';
|
||||
nameEl.textContent = name;
|
||||
nameEl.addEventListener('click', () => loadLevel(name));
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'action';
|
||||
btn.textContent = '删';
|
||||
btn.addEventListener('click', () => deleteLevel(name));
|
||||
div.appendChild(nameEl);
|
||||
div.appendChild(btn);
|
||||
el.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (!confirm('清空当前地图?')) return;
|
||||
for (const [, val] of placedTiles) scene.remove(val.mesh);
|
||||
placedTiles.clear();
|
||||
SFX.clear();
|
||||
toast('已清空');
|
||||
}
|
||||
|
||||
function newLevel() {
|
||||
clearAll();
|
||||
document.getElementById('level-name').value = '';
|
||||
}
|
||||
|
||||
function exportJSON() {
|
||||
const data = {
|
||||
name: document.getElementById('level-name').value || 'unnamed',
|
||||
gridSize: GRID_SIZE,
|
||||
tiles: Array.from(placedTiles.entries()).map(([key, val]) => {
|
||||
const [x, z] = key.split(',').map(Number);
|
||||
return { x, z, key: val.key, file: val.file, rotY: val.rotY };
|
||||
}),
|
||||
};
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
navigator.clipboard.writeText(json).then(() => toast('📤 JSON 已复制到剪贴板'));
|
||||
}
|
||||
|
||||
document.getElementById('btn-save').addEventListener('click', saveLevel);
|
||||
document.getElementById('btn-new').addEventListener('click', newLevel);
|
||||
document.getElementById('btn-clear').addEventListener('click', clearAll);
|
||||
document.getElementById('btn-export').addEventListener('click', exportJSON);
|
||||
document.getElementById('btn-rotate').addEventListener('click', () => {
|
||||
currentRotation = (currentRotation + Math.PI / 2) % (Math.PI * 2);
|
||||
toast(`已旋转: ${Math.round(currentRotation * 180 / Math.PI)}°`);
|
||||
});
|
||||
document.getElementById('btn-delete').addEventListener('click', () => {
|
||||
isDeleteMode = !isDeleteMode;
|
||||
document.getElementById('btn-delete').textContent =
|
||||
isDeleteMode ? '✅ 退出删除' : '❌ 删除模式(X)';
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Toast
|
||||
// ============================================================
|
||||
function toast(msg, isError = false) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = 'toast show' + (isError ? ' error' : '');
|
||||
if (isError) SFX.error();
|
||||
setTimeout(() => el.className = 'toast', 1800);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 渲染循环
|
||||
// ============================================================
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
// 窗口大小响应
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = container.clientWidth / container.clientHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 启动
|
||||
// ============================================================
|
||||
renderPalette();
|
||||
renderSavedList();
|
||||
|
||||
// 加载完成 — 移除 loading
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
|
||||
animate();
|
||||
|
||||
console.log('3D 关卡设计器已加载完成');
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
563
prototype/单词塔防/level-editor.html
Normal file
563
prototype/单词塔防/level-editor.html
Normal file
@@ -0,0 +1,563 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>🗺️ 单词塔防 · 关卡设计器</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: linear-gradient(180deg, #0d1b2a 0%, #16213e 100%);
|
||||
color: #eee;
|
||||
font-family: -apple-system, "PingFang SC", sans-serif;
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
user-select: none;
|
||||
}
|
||||
h1 {
|
||||
color: #ffd700;
|
||||
font-size: 22px;
|
||||
text-shadow: 2px 2px 0 #000;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.subtitle { color: #aaa; font-size: 13px; margin-bottom: 16px; }
|
||||
|
||||
/* 顶部工具栏 */
|
||||
.toolbar {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.toolbar input[type=text] {
|
||||
padding: 8px 12px;
|
||||
background: #1a1a2e;
|
||||
color: #ffd700;
|
||||
border: 1px solid #ffd700;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
width: 200px;
|
||||
}
|
||||
.toolbar button {
|
||||
padding: 8px 14px;
|
||||
background: linear-gradient(180deg, #4a90e2 0%, #357abd 100%);
|
||||
color: white;
|
||||
border: 2px solid #2a5a8e;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
font-family: inherit;
|
||||
}
|
||||
.toolbar button:hover { filter: brightness(1.15); }
|
||||
.toolbar button.primary {
|
||||
background: linear-gradient(180deg, #ff7a00 0%, #cc5500 100%);
|
||||
border-color: #ff9900;
|
||||
}
|
||||
.toolbar button.danger {
|
||||
background: linear-gradient(180deg, #d9534f 0%, #a52a2a 100%);
|
||||
border-color: #c0392b;
|
||||
}
|
||||
|
||||
/* 主编辑器布局 */
|
||||
.main {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr 240px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 左:调色板 */
|
||||
.palette {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.palette h3 {
|
||||
color: #ffd700;
|
||||
font-size: 13px;
|
||||
margin: 10px 0 6px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
.palette h3:first-child { margin-top: 0; }
|
||||
.palette .tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
.palette .ptile {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
background-size: cover;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 3px;
|
||||
transition: transform 0.1s;
|
||||
background-color: #333;
|
||||
}
|
||||
.palette .ptile:hover { transform: scale(1.15); border-color: #ffd700; }
|
||||
.palette .ptile.selected {
|
||||
border-color: #ff7a00;
|
||||
box-shadow: 0 0 10px #ff7a00;
|
||||
}
|
||||
.palette .special {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
background: linear-gradient(135deg, #4a90e2, #2a5a8e);
|
||||
}
|
||||
|
||||
/* 中:网格 */
|
||||
.canvas-area {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(15, 48px);
|
||||
grid-template-rows: repeat(8, 48px);
|
||||
background: #2a3a5a;
|
||||
border: 3px solid #ffd700;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.grid .cell {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-size: cover;
|
||||
background-color: #4a8a4a;
|
||||
cursor: crosshair;
|
||||
position: relative;
|
||||
}
|
||||
.grid .cell.path-marker::after {
|
||||
content: '🛣️';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.grid .cell.start-marker::after {
|
||||
content: '🚪';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.grid .cell.end-marker::after {
|
||||
content: '🏰';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.grid .cell.tower-marker::after {
|
||||
content: '🗼';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.help {
|
||||
margin-top: 10px;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 右:保存列表 */
|
||||
.save-panel {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.save-panel h3 {
|
||||
color: #ffd700;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.saved-levels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.saved-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.saved-item .name { color: #ffd700; flex: 1; cursor: pointer; }
|
||||
.saved-item .name:hover { color: #fff; }
|
||||
.saved-item .action {
|
||||
background: rgba(255, 100, 100, 0.2);
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #4a8a4a;
|
||||
color: #fff;
|
||||
padding: 12px 20px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 20px rgba(0, 255, 0, 0.4);
|
||||
z-index: 999;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
.toast.error { background: #a52a2a; box-shadow: 0 0 20px rgba(255, 0, 0, 0.4); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>🗺️ 单词塔防 · 关卡设计器</h1>
|
||||
<div class="subtitle">点 tile 选中 → 点格子放置 · 右键擦除回草地 · 用特殊 tile 标起点/终点/路径/塔位</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<input type="text" id="level-name" placeholder="给你的地图起个名字..." value="">
|
||||
<button class="primary" onclick="saveLevel()">💾 保存关卡</button>
|
||||
<button onclick="newLevel()">📄 新建</button>
|
||||
<button onclick="exportJSON()">📤 导出 JSON</button>
|
||||
<button onclick="importJSON()">📥 导入 JSON</button>
|
||||
<button class="danger" onclick="clearAll()">🗑️ 清空</button>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<!-- 左:tile palette -->
|
||||
<div class="palette" id="palette"></div>
|
||||
|
||||
<!-- 中:网格画布 -->
|
||||
<div class="canvas-area">
|
||||
<div class="grid" id="grid"></div>
|
||||
<div class="help">
|
||||
💡 左键 = 放置选中 tile · 右键 = 擦除(恢复草地) · 选"起点/终点/路径/塔位"特殊标记定义游戏元素
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右:已保存关卡 -->
|
||||
<div class="save-panel">
|
||||
<h3>📚 已保存关卡</h3>
|
||||
<div class="saved-levels" id="saved-levels"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
// ============================================================
|
||||
// Tile 调色板配置 — 分类的 Kenney TD sprite 编号
|
||||
// ============================================================
|
||||
const TILE_BASE = 'assets/kenney-td/PNG/Default%20size/towerDefense_tile';
|
||||
const DEFAULT_TILE = 24; // 默认草地
|
||||
|
||||
const PALETTE = {
|
||||
'🌿 草地': [24, 42, 70],
|
||||
'🟫 棕色路面': [2, 5, 6, 50, 60, 71],
|
||||
'🟨 沙地': [7, 34, 50, 106, 107, 108],
|
||||
'🌾 草+棕过渡(直)': [1, 23, 27, 28, 31, 47, 48, 49, 51, 52, 53, 55, 56, 57, 58],
|
||||
'↪️ 弯角(草+棕)': [3, 4, 25, 26, 29, 30, 73, 74, 75, 76, 79, 80],
|
||||
'🗿 塔基(空位)': [15, 38, 61, 82, 107],
|
||||
'🎯 塔基(瞄准)': [18, 41, 64, 85, 110],
|
||||
'🪨 装饰': [20, 21, 22, 54, 59, 118, 119],
|
||||
};
|
||||
|
||||
// 特殊标记 — 不是 sprite,而是游戏逻辑元素
|
||||
const SPECIAL_MARKERS = {
|
||||
'start': { label: '🚪 起点', color: '#00ff00' },
|
||||
'end': { label: '🏰 终点', color: '#ff3333' },
|
||||
'path': { label: '🛣️ 路径', color: '#8b6f47' },
|
||||
'tower': { label: '🗼 塔位', color: '#ffd700' },
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 网格状态
|
||||
// ============================================================
|
||||
const GRID_COLS = 15;
|
||||
const GRID_ROWS = 8;
|
||||
|
||||
let selected = { type: 'tile', value: DEFAULT_TILE }; // {type:'tile'|'special', value:tileId|'start'/'end'/'path'/'tower'}
|
||||
let gridData = createEmptyGrid();
|
||||
let markersData = createEmptyMarkers(); // 平行的"特殊标记"层
|
||||
|
||||
function createEmptyGrid() {
|
||||
return Array.from({length: GRID_ROWS}, () =>
|
||||
Array.from({length: GRID_COLS}, () => DEFAULT_TILE)
|
||||
);
|
||||
}
|
||||
function createEmptyMarkers() {
|
||||
return Array.from({length: GRID_ROWS}, () =>
|
||||
Array.from({length: GRID_COLS}, () => null)
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 渲染 Palette
|
||||
// ============================================================
|
||||
function renderPalette() {
|
||||
const el = document.getElementById('palette');
|
||||
el.innerHTML = '';
|
||||
// tile 分类
|
||||
for (const [cat, tiles] of Object.entries(PALETTE)) {
|
||||
const h = document.createElement('h3');
|
||||
h.textContent = cat;
|
||||
el.appendChild(h);
|
||||
const tilesDiv = document.createElement('div');
|
||||
tilesDiv.className = 'tiles';
|
||||
for (const tid of tiles) {
|
||||
const t = document.createElement('div');
|
||||
t.className = 'ptile';
|
||||
t.style.backgroundImage = `url(${TILE_BASE}${String(tid).padStart(3, '0')}.png)`;
|
||||
t.dataset.tid = tid;
|
||||
if (selected.type === 'tile' && selected.value === tid) t.classList.add('selected');
|
||||
t.addEventListener('click', () => {
|
||||
selected = { type: 'tile', value: tid };
|
||||
renderPalette();
|
||||
});
|
||||
tilesDiv.appendChild(t);
|
||||
}
|
||||
el.appendChild(tilesDiv);
|
||||
}
|
||||
// 特殊标记
|
||||
const sH = document.createElement('h3');
|
||||
sH.textContent = '⭐ 特殊标记';
|
||||
el.appendChild(sH);
|
||||
const sDiv = document.createElement('div');
|
||||
sDiv.className = 'tiles';
|
||||
for (const [key, def] of Object.entries(SPECIAL_MARKERS)) {
|
||||
const t = document.createElement('div');
|
||||
t.className = 'ptile special';
|
||||
t.style.background = `linear-gradient(135deg, ${def.color}, #333)`;
|
||||
t.textContent = def.label.split(' ')[0];
|
||||
t.title = def.label;
|
||||
if (selected.type === 'special' && selected.value === key) t.classList.add('selected');
|
||||
t.addEventListener('click', () => {
|
||||
selected = { type: 'special', value: key };
|
||||
renderPalette();
|
||||
});
|
||||
sDiv.appendChild(t);
|
||||
}
|
||||
el.appendChild(sDiv);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 渲染网格
|
||||
// ============================================================
|
||||
function renderGrid() {
|
||||
const el = document.getElementById('grid');
|
||||
el.innerHTML = '';
|
||||
for (let r = 0; r < GRID_ROWS; r++) {
|
||||
for (let c = 0; c < GRID_COLS; c++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'cell';
|
||||
const tid = gridData[r][c];
|
||||
cell.style.backgroundImage = `url(${TILE_BASE}${String(tid).padStart(3, '0')}.png)`;
|
||||
// 特殊标记
|
||||
const marker = markersData[r][c];
|
||||
if (marker === 'start') cell.classList.add('start-marker');
|
||||
else if (marker === 'end') cell.classList.add('end-marker');
|
||||
else if (marker === 'path') cell.classList.add('path-marker');
|
||||
else if (marker === 'tower') cell.classList.add('tower-marker');
|
||||
// 点击放置
|
||||
cell.addEventListener('click', () => placeAt(r, c));
|
||||
cell.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
eraseAt(r, c);
|
||||
});
|
||||
el.appendChild(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function placeAt(r, c) {
|
||||
if (selected.type === 'tile') {
|
||||
gridData[r][c] = selected.value;
|
||||
} else {
|
||||
markersData[r][c] = selected.value;
|
||||
}
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
function eraseAt(r, c) {
|
||||
gridData[r][c] = DEFAULT_TILE;
|
||||
markersData[r][c] = null;
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 保存 / 加载(localStorage)
|
||||
// ============================================================
|
||||
function saveLevel() {
|
||||
const name = document.getElementById('level-name').value.trim();
|
||||
if (!name) { toast('请输入关卡名字', true); return; }
|
||||
const all = loadAllLevels();
|
||||
all[name] = {
|
||||
name,
|
||||
cols: GRID_COLS,
|
||||
rows: GRID_ROWS,
|
||||
tiles: gridData,
|
||||
markers: markersData,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem('wordTD-levels', JSON.stringify(all));
|
||||
toast(`✅ 已保存「${name}」`);
|
||||
renderSavedList();
|
||||
}
|
||||
|
||||
function loadAllLevels() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('wordTD-levels') || '{}');
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function loadLevel(name) {
|
||||
const all = loadAllLevels();
|
||||
const lvl = all[name];
|
||||
if (!lvl) return;
|
||||
gridData = lvl.tiles;
|
||||
markersData = lvl.markers || createEmptyMarkers();
|
||||
document.getElementById('level-name').value = lvl.name;
|
||||
renderGrid();
|
||||
toast(`📂 已加载「${name}」`);
|
||||
}
|
||||
|
||||
function deleteLevel(name) {
|
||||
if (!confirm(`确定删除「${name}」吗?`)) return;
|
||||
const all = loadAllLevels();
|
||||
delete all[name];
|
||||
localStorage.setItem('wordTD-levels', JSON.stringify(all));
|
||||
toast(`🗑️ 已删除「${name}」`);
|
||||
renderSavedList();
|
||||
}
|
||||
|
||||
function renderSavedList() {
|
||||
const el = document.getElementById('saved-levels');
|
||||
const all = loadAllLevels();
|
||||
const names = Object.keys(all).sort();
|
||||
if (names.length === 0) {
|
||||
el.innerHTML = '<div style="color:#666;font-size:12px;text-align:center;padding:20px 0">还没有保存的关卡<br>设计完点保存就在这里</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = '';
|
||||
for (const name of names) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'saved-item';
|
||||
item.innerHTML = `
|
||||
<span class="name">${name}</span>
|
||||
<button class="action" onclick="deleteLevel('${name.replace(/'/g, "\\'")}')">删</button>
|
||||
`;
|
||||
item.querySelector('.name').addEventListener('click', () => loadLevel(name));
|
||||
el.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 其他工具
|
||||
// ============================================================
|
||||
function newLevel() {
|
||||
gridData = createEmptyGrid();
|
||||
markersData = createEmptyMarkers();
|
||||
document.getElementById('level-name').value = '';
|
||||
renderGrid();
|
||||
toast('📄 新建空白关卡');
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (!confirm('清空当前画布?')) return;
|
||||
gridData = createEmptyGrid();
|
||||
markersData = createEmptyMarkers();
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
function exportJSON() {
|
||||
const data = {
|
||||
name: document.getElementById('level-name').value || 'unnamed',
|
||||
cols: GRID_COLS,
|
||||
rows: GRID_ROWS,
|
||||
tiles: gridData,
|
||||
markers: markersData,
|
||||
};
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
navigator.clipboard.writeText(json).then(() => {
|
||||
toast('📤 JSON 已复制到剪贴板');
|
||||
}).catch(() => {
|
||||
prompt('复制下面 JSON:', json);
|
||||
});
|
||||
}
|
||||
|
||||
function importJSON() {
|
||||
const input = prompt('粘贴 JSON:');
|
||||
if (!input) return;
|
||||
try {
|
||||
const data = JSON.parse(input);
|
||||
gridData = data.tiles;
|
||||
markersData = data.markers || createEmptyMarkers();
|
||||
document.getElementById('level-name').value = data.name || '';
|
||||
renderGrid();
|
||||
toast('📥 导入成功');
|
||||
} catch (e) {
|
||||
toast('❌ JSON 格式错误', true);
|
||||
}
|
||||
}
|
||||
|
||||
function toast(msg, isError = false) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = 'toast show' + (isError ? ' error' : '');
|
||||
setTimeout(() => el.className = 'toast', 2000);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 启动
|
||||
// ============================================================
|
||||
renderPalette();
|
||||
renderGrid();
|
||||
renderSavedList();
|
||||
|
||||
// 防止页面右键菜单
|
||||
document.addEventListener('contextmenu', (e) => {
|
||||
if (e.target.closest('.grid')) e.preventDefault();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
110
prototype/单词塔防/sprite-browser.html
Normal file
110
prototype/单词塔防/sprite-browser.html
Normal file
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Kenney TD Sprite 浏览器</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, "PingFang SC", sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
h1 { color: #ffd700; }
|
||||
.info {
|
||||
background: rgba(255, 215, 0, 0.1);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.tile {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 6px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
position: relative;
|
||||
}
|
||||
.tile:hover {
|
||||
transform: scale(1.5);
|
||||
z-index: 10;
|
||||
box-shadow: 0 0 20px rgba(255, 215, 0, 0.8);
|
||||
}
|
||||
.tile img {
|
||||
display: block;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.tile .num {
|
||||
color: #666;
|
||||
font-size: 10px;
|
||||
margin-top: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.search {
|
||||
margin: 10px 0;
|
||||
padding: 8px 12px;
|
||||
background: #2a2a4e;
|
||||
color: #ffd700;
|
||||
border: 1px solid #ffd700;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
width: 200px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>🔍 Kenney TD Sprite 浏览器</h1>
|
||||
<div class="info">
|
||||
共 299 个 sprite,64×64 像素。鼠标悬停放大查看。点击复制 tile 编号。<br>
|
||||
用途:为单词塔防 demo 选取塔/怪/路径/UI 等元素。
|
||||
</div>
|
||||
|
||||
<input class="search" id="search" placeholder="跳转编号(如 042)">
|
||||
|
||||
<div class="grid" id="grid"></div>
|
||||
|
||||
<script>
|
||||
const grid = document.getElementById('grid');
|
||||
for (let i = 1; i <= 299; i++) {
|
||||
const id = String(i).padStart(3, '0');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'tile';
|
||||
div.id = 'tile-' + id;
|
||||
div.innerHTML = `
|
||||
<img src="assets/kenney-td/PNG/Default%20size/towerDefense_tile${id}.png" alt="tile${id}" loading="lazy">
|
||||
<div class="num">${id}</div>
|
||||
`;
|
||||
div.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText('towerDefense_tile' + id + '.png');
|
||||
const orig = div.querySelector('.num').textContent;
|
||||
div.querySelector('.num').textContent = '✓ 已复制';
|
||||
setTimeout(() => div.querySelector('.num').textContent = orig, 1000);
|
||||
});
|
||||
grid.appendChild(div);
|
||||
}
|
||||
|
||||
document.getElementById('search').addEventListener('input', (e) => {
|
||||
const v = e.target.value.padStart(3, '0');
|
||||
const target = document.getElementById('tile-' + v);
|
||||
if (target) {
|
||||
target.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||
target.style.background = '#ffd700';
|
||||
setTimeout(() => target.style.background = '#fff', 1500);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
608
prototype/跳一跳-3d/index.html
Normal file
608
prototype/跳一跳-3d/index.html
Normal file
@@ -0,0 +1,608 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>🦘 3D 跳一跳 · 穹狼学徒</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: #87ceeb;
|
||||
color: #333;
|
||||
font-family: -apple-system, "PingFang SC", sans-serif;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
#scene { position: fixed; inset: 0; }
|
||||
|
||||
#hud {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
padding: 12px 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||||
z-index: 10;
|
||||
}
|
||||
#hud .label { font-size: 12px; color: #888; margin-bottom: 2px; }
|
||||
#hud .score { font-size: 32px; color: #ff6b35; font-weight: bold; line-height: 1; }
|
||||
#hud .best { font-size: 12px; color: #4a90e2; margin-top: 4px; }
|
||||
|
||||
#power-bar-container {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 220px;
|
||||
height: 16px;
|
||||
background: rgba(255,255,255,0.6);
|
||||
border-radius: 8px;
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
}
|
||||
#power-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, #ffeb3b 0%, #ff9800 50%, #f44336 100%);
|
||||
transition: width 0.05s linear;
|
||||
}
|
||||
#power-label {
|
||||
position: fixed;
|
||||
bottom: 56px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: #fff;
|
||||
text-shadow: 1px 1px 3px rgba(0,0,0,0.5);
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#hint {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
text-shadow: 1px 2px 4px rgba(0,0,0,0.5);
|
||||
font-weight: bold;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
text-align: center;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
#hint.hide { opacity: 0; }
|
||||
|
||||
#end-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
flex-direction: column;
|
||||
}
|
||||
#end-overlay.show { display: flex; }
|
||||
#end-overlay h2 { color: #fff; font-size: 48px; margin-bottom: 12px; text-shadow: 2px 2px 0 #333; }
|
||||
#end-overlay .final-score {
|
||||
color: #ffd700;
|
||||
font-size: 80px;
|
||||
font-weight: bold;
|
||||
text-shadow: 3px 3px 0 #333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
#end-overlay .stats {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
line-height: 1.8;
|
||||
}
|
||||
#end-overlay button {
|
||||
padding: 14px 32px;
|
||||
background: linear-gradient(180deg, #ff6b35 0%, #d24213 100%);
|
||||
color: white;
|
||||
border: 3px solid #fff;
|
||||
border-radius: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
#end-overlay button:hover { transform: scale(1.05); }
|
||||
|
||||
.help {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
z-index: 10;
|
||||
max-width: 200px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="scene"></div>
|
||||
|
||||
<div id="hud">
|
||||
<div class="label">分数</div>
|
||||
<div class="score" id="score">0</div>
|
||||
<div class="best" id="best">最高 0</div>
|
||||
</div>
|
||||
|
||||
<div class="help">
|
||||
💡 <b>按住空格 / 鼠标</b> 蓄力<br>
|
||||
💡 <b>松开</b> 跳跃<br>
|
||||
💡 落到下一个平台中心得分多!
|
||||
</div>
|
||||
|
||||
<div id="hint">按住空格蓄力,松开跳跃</div>
|
||||
|
||||
<div id="power-label">蓄力中...</div>
|
||||
<div id="power-bar-container"><div id="power-bar"></div></div>
|
||||
|
||||
<div id="end-overlay">
|
||||
<h2>💥 GAME OVER</h2>
|
||||
<div class="final-score" id="final-score">0</div>
|
||||
<div class="stats" id="end-stats"></div>
|
||||
<button onclick="location.reload()">🔄 再来一局</button>
|
||||
</div>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
|
||||
// ============================================================
|
||||
// 音效 — Web Audio API
|
||||
// ============================================================
|
||||
let audioCtx = null;
|
||||
function ensureAudio() {
|
||||
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
if (audioCtx.state === 'suspended') audioCtx.resume();
|
||||
}
|
||||
function tone(freq, dur, type='sine', vol=0.15) {
|
||||
try {
|
||||
ensureAudio();
|
||||
const osc = audioCtx.createOscillator();
|
||||
const gain = audioCtx.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.value = freq;
|
||||
osc.connect(gain);
|
||||
gain.connect(audioCtx.destination);
|
||||
const t = audioCtx.currentTime;
|
||||
gain.gain.setValueAtTime(vol, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + dur);
|
||||
osc.start(t);
|
||||
osc.stop(t + dur);
|
||||
} catch(e) {}
|
||||
}
|
||||
const SFX = {
|
||||
charge: (level) => tone(200 + level * 600, 0.04, 'square', 0.06 + level * 0.04),
|
||||
jump: () => { tone(523, 0.06, 'triangle', 0.14); setTimeout(() => tone(784, 0.08, 'triangle', 0.12), 30); },
|
||||
land: () => { tone(330, 0.08, 'sine', 0.18); setTimeout(() => tone(440, 0.1, 'sine', 0.14), 50); },
|
||||
perfect:() => { [659, 880, 1175].forEach((f, i) => setTimeout(() => tone(f, 0.1, 'sine', 0.18), i*60)); },
|
||||
fall: () => { tone(200, 0.3, 'sawtooth', 0.2); setTimeout(() => tone(100, 0.5, 'sawtooth', 0.18), 200); },
|
||||
score: () => tone(880, 0.1, 'sine', 0.14),
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 场景设置
|
||||
// ============================================================
|
||||
const container = document.getElementById('scene');
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0xb3e5fc);
|
||||
scene.fog = new THREE.Fog(0xb3e5fc, 12, 30);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 100);
|
||||
camera.position.set(8, 8, 8);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 0.85);
|
||||
sun.position.set(8, 18, 6);
|
||||
sun.castShadow = true;
|
||||
sun.shadow.mapSize.set(1024, 1024);
|
||||
sun.shadow.camera.left = -15;
|
||||
sun.shadow.camera.right = 15;
|
||||
sun.shadow.camera.top = 15;
|
||||
sun.shadow.camera.bottom = -15;
|
||||
scene.add(sun);
|
||||
|
||||
// 半透明地面(掉下去的视觉边界)
|
||||
const groundGeo = new THREE.PlaneGeometry(50, 50);
|
||||
const groundMat = new THREE.MeshLambertMaterial({ color: 0x4a8a4a, transparent: true, opacity: 0.4 });
|
||||
const ground = new THREE.Mesh(groundGeo, groundMat);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.position.y = -3;
|
||||
ground.receiveShadow = true;
|
||||
scene.add(ground);
|
||||
|
||||
// ============================================================
|
||||
// 游戏状态
|
||||
// ============================================================
|
||||
const PLATFORM_COLORS = [0xff7043, 0x66bb6a, 0xab47bc, 0x42a5f5, 0xffca28, 0xec407a];
|
||||
let platforms = [];
|
||||
let player = null;
|
||||
let score = 0;
|
||||
let best = parseInt(localStorage.getItem('jump3d-best') || '0', 10);
|
||||
let isCharging = false;
|
||||
let chargeStart = 0;
|
||||
let isJumping = false;
|
||||
let gameOver = false;
|
||||
let cameraTarget = new THREE.Vector3(0, 0, 0);
|
||||
|
||||
const MAX_CHARGE = 1500; // ms
|
||||
const MIN_DISTANCE = 2.5;
|
||||
const MAX_DISTANCE = 6;
|
||||
const PLATFORM_SIZE = 1.4;
|
||||
|
||||
document.getElementById('best').textContent = `最高 ${best}`;
|
||||
|
||||
// ============================================================
|
||||
// 创建平台
|
||||
// ============================================================
|
||||
function createPlatform(x, z, color) {
|
||||
const geo = new THREE.BoxGeometry(PLATFORM_SIZE, 0.6, PLATFORM_SIZE);
|
||||
const mat = new THREE.MeshLambertMaterial({ color });
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.position.set(x, -0.3, z);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
scene.add(mesh);
|
||||
return { mesh, x, z };
|
||||
}
|
||||
|
||||
function spawnNextPlatform() {
|
||||
const last = platforms[platforms.length - 1];
|
||||
// 随机方向(向 +x 或 +z 之一)
|
||||
const direction = Math.random() < 0.5 ? 'x' : 'z';
|
||||
const distance = MIN_DISTANCE + Math.random() * (MAX_DISTANCE - MIN_DISTANCE);
|
||||
const newX = direction === 'x' ? last.x + distance : last.x;
|
||||
const newZ = direction === 'z' ? last.z + distance : last.z;
|
||||
const color = PLATFORM_COLORS[(platforms.length) % PLATFORM_COLORS.length];
|
||||
const p = createPlatform(newX, newZ, color);
|
||||
platforms.push(p);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 创建玩家(小球 + 小帽子)
|
||||
// ============================================================
|
||||
function createPlayer() {
|
||||
const group = new THREE.Group();
|
||||
// 身体(球)
|
||||
const bodyGeo = new THREE.SphereGeometry(0.32, 24, 24);
|
||||
const bodyMat = new THREE.MeshLambertMaterial({ color: 0xffd700 });
|
||||
const body = new THREE.Mesh(bodyGeo, bodyMat);
|
||||
body.position.y = 0.32;
|
||||
body.castShadow = true;
|
||||
group.add(body);
|
||||
// 帽子(小锥)
|
||||
const hatGeo = new THREE.ConeGeometry(0.18, 0.32, 12);
|
||||
const hatMat = new THREE.MeshLambertMaterial({ color: 0xc0392b });
|
||||
const hat = new THREE.Mesh(hatGeo, hatMat);
|
||||
hat.position.y = 0.78;
|
||||
hat.castShadow = true;
|
||||
group.add(hat);
|
||||
// 眼睛
|
||||
const eyeGeo = new THREE.SphereGeometry(0.04, 8, 8);
|
||||
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x000000 });
|
||||
const eye1 = new THREE.Mesh(eyeGeo, eyeMat);
|
||||
eye1.position.set(0.18, 0.4, 0.22);
|
||||
group.add(eye1);
|
||||
const eye2 = new THREE.Mesh(eyeGeo, eyeMat);
|
||||
eye2.position.set(-0.18, 0.4, 0.22);
|
||||
group.add(eye2);
|
||||
return { group, body, hat };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 初始化
|
||||
// ============================================================
|
||||
platforms.push(createPlatform(0, 0, PLATFORM_COLORS[0]));
|
||||
spawnNextPlatform();
|
||||
|
||||
const playerObj = createPlayer();
|
||||
player = playerObj.group;
|
||||
player.position.set(0, 0, 0);
|
||||
scene.add(player);
|
||||
|
||||
// 初始相机目标
|
||||
const lookAt = new THREE.Vector3(
|
||||
(platforms[0].x + platforms[1].x) / 2,
|
||||
0,
|
||||
(platforms[0].z + platforms[1].z) / 2
|
||||
);
|
||||
cameraTarget.copy(lookAt);
|
||||
camera.lookAt(lookAt);
|
||||
|
||||
// ============================================================
|
||||
// 蓄力 + 跳跃
|
||||
// ============================================================
|
||||
function startCharge() {
|
||||
if (isJumping || gameOver) return;
|
||||
isCharging = true;
|
||||
chargeStart = performance.now();
|
||||
document.getElementById('hint').classList.add('hide');
|
||||
}
|
||||
|
||||
function releaseJump() {
|
||||
if (!isCharging || gameOver) return;
|
||||
isCharging = false;
|
||||
const elapsed = Math.min(performance.now() - chargeStart, MAX_CHARGE);
|
||||
const power = elapsed / MAX_CHARGE; // 0..1
|
||||
jumpWithPower(power);
|
||||
}
|
||||
|
||||
function jumpWithPower(power) {
|
||||
isJumping = true;
|
||||
document.getElementById('power-bar').style.width = '0%';
|
||||
|
||||
const current = platforms[platforms.length - 2];
|
||||
const next = platforms[platforms.length - 1];
|
||||
// 跳跃方向(指向下一个平台)
|
||||
const dx = next.x - current.x;
|
||||
const dz = next.z - current.z;
|
||||
const targetDist = Math.sqrt(dx*dx + dz*dz);
|
||||
// 实际跳跃距离根据蓄力 — 最大 MAX_DISTANCE * 1.2 (轻微 overshoot 可能)
|
||||
const jumpDistance = power * (MAX_DISTANCE * 1.2);
|
||||
const ratio = jumpDistance / targetDist; // 跳跃终点相对目标的比例
|
||||
|
||||
// 起点 / 终点
|
||||
const startX = current.x;
|
||||
const startZ = current.z;
|
||||
const endX = startX + dx * ratio;
|
||||
const endZ = startZ + dz * ratio;
|
||||
|
||||
SFX.jump();
|
||||
|
||||
// 跳跃动画(抛物线)— 用 tween 风格
|
||||
const duration = 600;
|
||||
const startTime = performance.now();
|
||||
const maxY = 1.5 + power * 1.5; // 蓄力越大跳得越高
|
||||
// 玩家身体压扁恢复
|
||||
playerObj.body.scale.y = 1;
|
||||
|
||||
function animateJump() {
|
||||
if (gameOver) return;
|
||||
const now = performance.now();
|
||||
const t = Math.min((now - startTime) / duration, 1);
|
||||
// 抛物线 y = -4*h*t*(t-1)
|
||||
const y = -4 * maxY * t * (t - 1);
|
||||
player.position.x = startX + (endX - startX) * t;
|
||||
player.position.z = startZ + (endZ - startZ) * t;
|
||||
player.position.y = y;
|
||||
// 旋转一周
|
||||
player.rotation.y = t * Math.PI * 2;
|
||||
if (t < 1) {
|
||||
requestAnimationFrame(animateJump);
|
||||
} else {
|
||||
onLanded(endX, endZ, next, current, jumpDistance, targetDist);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(animateJump);
|
||||
}
|
||||
|
||||
function onLanded(landX, landZ, nextPlatform, currentPlatform, jumpDistance, targetDist) {
|
||||
// 判定落地是否在 next 平台上
|
||||
const halfSize = PLATFORM_SIZE / 2;
|
||||
const onNext = Math.abs(landX - nextPlatform.x) < halfSize &&
|
||||
Math.abs(landZ - nextPlatform.z) < halfSize;
|
||||
const onCurrent = Math.abs(landX - currentPlatform.x) < halfSize &&
|
||||
Math.abs(landZ - currentPlatform.z) < halfSize;
|
||||
|
||||
if (onNext) {
|
||||
// 着陆 — 落到下一个平台
|
||||
const distToCenter = Math.sqrt(
|
||||
(landX - nextPlatform.x)**2 + (landZ - nextPlatform.z)**2
|
||||
);
|
||||
const isPerfect = distToCenter < 0.15;
|
||||
player.position.set(landX, 0, landZ);
|
||||
if (isPerfect) {
|
||||
score += 3;
|
||||
SFX.perfect();
|
||||
spawnFloatText('+3 PERFECT!', '#ff6b35', landX, landZ);
|
||||
} else {
|
||||
score += 1;
|
||||
SFX.land();
|
||||
SFX.score();
|
||||
spawnFloatText('+1', '#4caf50', landX, landZ);
|
||||
}
|
||||
document.getElementById('score').textContent = score;
|
||||
if (score > best) {
|
||||
best = score;
|
||||
localStorage.setItem('jump3d-best', best);
|
||||
document.getElementById('best').textContent = `最高 ${best}`;
|
||||
}
|
||||
isJumping = false;
|
||||
// 弹簧压扁
|
||||
playerObj.body.scale.y = 0.6;
|
||||
setTimeout(() => { playerObj.body.scale.y = 1; }, 150);
|
||||
// 生成下一个平台
|
||||
spawnNextPlatform();
|
||||
updateCameraTarget();
|
||||
} else if (onCurrent) {
|
||||
// 跳得不够,回到当前平台
|
||||
player.position.set(landX, 0, landZ);
|
||||
isJumping = false;
|
||||
spawnFloatText('跳得不够远~', '#ff9800', landX, landZ);
|
||||
SFX.land();
|
||||
} else {
|
||||
// 失败 — 摔下去
|
||||
SFX.fall();
|
||||
fallAnimation(landX, landZ);
|
||||
}
|
||||
}
|
||||
|
||||
function fallAnimation(x, z) {
|
||||
const startY = player.position.y;
|
||||
const startT = performance.now();
|
||||
function fall() {
|
||||
if (gameOver) return;
|
||||
const t = (performance.now() - startT) / 800;
|
||||
player.position.y = startY - t * t * 8;
|
||||
player.rotation.z += 0.15;
|
||||
if (player.position.y > -8) {
|
||||
requestAnimationFrame(fall);
|
||||
} else {
|
||||
endGame();
|
||||
}
|
||||
}
|
||||
fall();
|
||||
}
|
||||
|
||||
function updateCameraTarget() {
|
||||
if (platforms.length < 2) return;
|
||||
const last2 = platforms.slice(-2);
|
||||
cameraTarget.set(
|
||||
(last2[0].x + last2[1].x) / 2,
|
||||
0,
|
||||
(last2[0].z + last2[1].z) / 2
|
||||
);
|
||||
}
|
||||
|
||||
function endGame() {
|
||||
gameOver = true;
|
||||
document.getElementById('final-score').textContent = score;
|
||||
document.getElementById('end-stats').innerHTML = `
|
||||
🏆 最高记录: <b>${best}</b><br>
|
||||
🎯 本局: <b>${score}</b><br>
|
||||
${score === best && score > 0 ? '✨ 新纪录!' : ''}
|
||||
`;
|
||||
document.getElementById('end-overlay').classList.add('show');
|
||||
document.getElementById('power-bar-container').style.display = 'none';
|
||||
document.getElementById('power-label').style.display = 'none';
|
||||
}
|
||||
|
||||
function spawnFloatText(text, color, x, z) {
|
||||
// 屏幕坐标转换
|
||||
const worldPos = new THREE.Vector3(x, 1.5, z);
|
||||
worldPos.project(camera);
|
||||
const sx = (worldPos.x * 0.5 + 0.5) * window.innerWidth;
|
||||
const sy = (-worldPos.y * 0.5 + 0.5) * window.innerHeight;
|
||||
const div = document.createElement('div');
|
||||
div.style.cssText = `
|
||||
position: fixed; left: ${sx}px; top: ${sy}px;
|
||||
color: ${color}; font-size: 28px; font-weight: bold;
|
||||
text-shadow: 2px 2px 0 #fff;
|
||||
pointer-events: none; z-index: 50;
|
||||
transition: all 1s ease-out;
|
||||
`;
|
||||
div.textContent = text;
|
||||
document.body.appendChild(div);
|
||||
setTimeout(() => {
|
||||
div.style.transform = 'translateY(-60px)';
|
||||
div.style.opacity = '0';
|
||||
}, 50);
|
||||
setTimeout(() => div.remove(), 1100);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 输入
|
||||
// ============================================================
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Space' && !isCharging && !isJumping) {
|
||||
e.preventDefault();
|
||||
startCharge();
|
||||
}
|
||||
});
|
||||
window.addEventListener('keyup', (e) => {
|
||||
if (e.code === 'Space' && isCharging) {
|
||||
e.preventDefault();
|
||||
releaseJump();
|
||||
}
|
||||
});
|
||||
window.addEventListener('mousedown', (e) => {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
if (!isCharging && !isJumping) startCharge();
|
||||
});
|
||||
window.addEventListener('mouseup', (e) => {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
if (isCharging) releaseJump();
|
||||
});
|
||||
// 触屏支持
|
||||
window.addEventListener('touchstart', (e) => {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
if (!isCharging && !isJumping) { e.preventDefault(); startCharge(); }
|
||||
}, {passive: false});
|
||||
window.addEventListener('touchend', (e) => {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
if (isCharging) { e.preventDefault(); releaseJump(); }
|
||||
}, {passive: false});
|
||||
|
||||
// ============================================================
|
||||
// 主循环
|
||||
// ============================================================
|
||||
let lastChargeSoundTime = 0;
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
// 蓄力可视化
|
||||
if (isCharging) {
|
||||
const elapsed = Math.min(performance.now() - chargeStart, MAX_CHARGE);
|
||||
const power = elapsed / MAX_CHARGE;
|
||||
document.getElementById('power-bar').style.width = (power * 100) + '%';
|
||||
// 玩家压扁
|
||||
playerObj.body.scale.y = 1 - power * 0.5;
|
||||
playerObj.body.scale.x = 1 + power * 0.3;
|
||||
playerObj.body.scale.z = 1 + power * 0.3;
|
||||
// 蓄力声(节奏感)
|
||||
if (performance.now() - lastChargeSoundTime > 80) {
|
||||
SFX.charge(power);
|
||||
lastChargeSoundTime = performance.now();
|
||||
}
|
||||
} else if (!isJumping) {
|
||||
document.getElementById('power-bar').style.width = '0%';
|
||||
playerObj.body.scale.set(1, 1, 1);
|
||||
}
|
||||
|
||||
// 平滑相机跟随
|
||||
const desiredCamX = cameraTarget.x + 8;
|
||||
const desiredCamZ = cameraTarget.z + 8;
|
||||
camera.position.x += (desiredCamX - camera.position.x) * 0.08;
|
||||
camera.position.z += (desiredCamZ - camera.position.z) * 0.08;
|
||||
camera.lookAt(cameraTarget);
|
||||
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
|
||||
// 一段时间后隐藏提示
|
||||
setTimeout(() => document.getElementById('hint').classList.add('hide'), 5000);
|
||||
|
||||
animate();
|
||||
|
||||
// 暴露 API 用于自测
|
||||
window.__game = { score: () => score, platforms, player, jumpWithPower };
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"accessToken": "dcfa7be67b303944afd07dd110cb8710",
|
||||
"refreshToken": "22be4dcc392d3085be63bf2ff0b8aa2b",
|
||||
"expireTime": 1775203708.1550488,
|
||||
"clientId": "dingxtxlbrv3zv2uuep4",
|
||||
"clientSecret": "fBe5GthCC9mG6lv73Mb4KB13pWzW7gcVCdDKPjWmWj6wQ1kNtzLaxIDrsVvb2lTh"
|
||||
}
|
||||
@@ -94,20 +94,20 @@ def do_oauth(config):
|
||||
f"&prompt=consent"
|
||||
)
|
||||
|
||||
print("正在启动本地服务器等待授权回调...")
|
||||
print(f"请在浏览器中完成钉钉登录授权。")
|
||||
print(f"如果浏览器没有自动打开,请手动访问:\n{auth_url}\n")
|
||||
|
||||
# 启动本地 HTTP 服务器
|
||||
server = HTTPServer(("127.0.0.1", 18765), OAuthCallbackHandler)
|
||||
# 直接手动模式:打印链接,让用户粘贴 authCode
|
||||
print("=" * 60)
|
||||
print("请用浏览器打开以下链接,完成钉钉登录授权:")
|
||||
print(f"\n{auth_url}\n")
|
||||
print("授权后浏览器地址栏会出现类似:")
|
||||
print(" http://127.0.0.1:18765/callback?authCode=xxxxxx&...")
|
||||
print("\n从地址栏复制 authCode= 后面的值粘贴到这里:")
|
||||
print("=" * 60)
|
||||
webbrowser.open(auth_url)
|
||||
auth_code = input("authCode: ").strip()
|
||||
if not auth_code:
|
||||
print("[auth] 未输入 authCode,授权取消")
|
||||
return None
|
||||
|
||||
# 等待回调
|
||||
while OAuthCallbackHandler.auth_code is None:
|
||||
server.handle_request()
|
||||
|
||||
auth_code = OAuthCallbackHandler.auth_code
|
||||
server.server_close()
|
||||
print(f"[auth] 收到授权码")
|
||||
|
||||
# 用 auth_code 换取 user access token
|
||||
|
||||
Reference in New Issue
Block a user