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:
Rocky
2026-05-18 23:04:54 +02:00
parent 25ec5f0c9c
commit 1c5e72676b
13 changed files with 5290 additions and 23 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>