Files
AICODE2026/prototype/跳一跳-3d/index.html
Rocky 1c5e72676b 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>
2026-05-18 23:04:54 +02:00

609 lines
18 KiB
HTML

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