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:
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>
|
||||
Reference in New Issue
Block a user