## 主要变更 ### 课程设计 - 大纲扩展到 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>
609 lines
18 KiB
HTML
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>
|