feat: AICODE-06 春季后半 7 课大纲 + 第 12 课教案 + prototype 工程产物

## 主要变更

### 课程设计
- 大纲扩展到 18 课(新增第 12-18 课:单词塔防 3D 大项目)
- 引入 AI 三角色协作工作流(Planner / Reviewer / Tester)作为整学期框架
- 每课详化:核心概念 + 误概念预设 + 教学锚点 + 学生产出 + 老师课前要准备

### 第 12 课教案(完整逐字稿)
- 主题:Skills 入门 - 用 game-studio 做跳一跳
- 90 分钟 4C 结构 + 诊断点 + 分支策略
- 5 个误概念预设 + AI 助教提示词模板 + 教师备课指南

### prototype 工程产物(可玩 demo)
- 跳一跳-3d/index.html:Three.js 3D 跳一跳(蓄力 + Web Audio 音效 + PERFECT 命中)
- 单词塔防/game-3d.html:完整 3D 塔防(三阶段 + 商店 + 卡片 + 战斗循环,15 击杀完美胜利)
- 单词塔防/level-editor-3d.html:3D 关卡设计器(Kenney GLB 模型 + localStorage 保存)
- 单词塔防/level-editor.html:2D 关卡设计器(原型保留)
- 单词塔防/index.html:2D 塔防原型(原型保留)

### 工程加固
- .gitignore 加强:排除 token、Kenney 大素材、调试截图、第三方插件、Playwright 临时
- 从 git tracking 移除 scripts/.dingtalk_token.json(本地保留)
- scripts/sync_to_dingtalk.py:OAuth 流程改为手动 authCode 粘贴(避免本地 server 受限)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rocky
2026-05-18 23:04:54 +02:00
parent 25ec5f0c9c
commit 1c5e72676b
13 changed files with 5290 additions and 23 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,700 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>🗺️ 单词塔防 · 3D 关卡设计器</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0d1b2a;
color: #eee;
font-family: -apple-system, "PingFang SC", sans-serif;
overflow: hidden;
}
#header {
position: fixed;
top: 0; left: 0; right: 0;
background: rgba(13, 27, 42, 0.92);
z-index: 10;
padding: 8px 16px;
border-bottom: 1px solid #ffd700;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
#header h1 {
color: #ffd700;
font-size: 16px;
text-shadow: 1px 1px 0 #000;
}
#header input {
padding: 6px 10px;
background: #1a1a2e;
color: #ffd700;
border: 1px solid #ffd700;
border-radius: 4px;
font-size: 13px;
width: 180px;
}
#header button {
padding: 6px 12px;
background: linear-gradient(180deg, #4a90e2 0%, #357abd 100%);
color: white;
border: 2px solid #2a5a8e;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: bold;
font-family: inherit;
}
#header button:hover { filter: brightness(1.2); }
#header button.primary {
background: linear-gradient(180deg, #ff7a00 0%, #cc5500 100%);
border-color: #ff9900;
}
#palette {
position: fixed;
left: 0; top: 50px;
bottom: 0;
width: 200px;
background: rgba(13, 27, 42, 0.88);
border-right: 1px solid #444;
padding: 10px;
overflow-y: auto;
z-index: 5;
}
#palette h3 {
color: #ffd700;
font-size: 12px;
margin: 8px 0 4px;
padding-bottom: 2px;
border-bottom: 1px solid #333;
}
#palette h3:first-child { margin-top: 0; }
#palette .ptile {
padding: 6px 10px;
margin-bottom: 3px;
background: rgba(255,255,255,0.05);
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
color: #eee;
transition: all 0.1s;
}
#palette .ptile:hover {
background: rgba(255, 215, 0, 0.15);
border-color: #ffd700;
}
#palette .ptile.selected {
background: linear-gradient(180deg, #ff7a00 0%, #cc5500 100%);
border-color: #ffd700;
color: white;
font-weight: bold;
}
#right-panel {
position: fixed;
right: 0; top: 50px;
bottom: 0;
width: 200px;
background: rgba(13, 27, 42, 0.88);
border-left: 1px solid #444;
padding: 10px;
overflow-y: auto;
z-index: 5;
}
#right-panel h3 {
color: #ffd700;
font-size: 12px;
margin-bottom: 6px;
}
.saved-item {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255, 255, 255, 0.05);
padding: 4px 8px;
margin-bottom: 3px;
border-radius: 3px;
font-size: 11px;
}
.saved-item .name { color: #ffd700; flex: 1; cursor: pointer; }
.saved-item .name:hover { color: #fff; }
.saved-item .action {
background: rgba(255, 100, 100, 0.2);
border: none;
color: #fff;
padding: 1px 6px;
border-radius: 2px;
cursor: pointer;
font-size: 10px;
}
#scene-container {
position: absolute;
left: 200px;
right: 200px;
top: 50px;
bottom: 0;
}
#status {
position: fixed;
bottom: 10px;
left: 210px;
background: rgba(0,0,0,0.7);
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
color: #ffd700;
z-index: 6;
}
#loading {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
display: flex;
align-items: center;
justify-content: center;
color: #ffd700;
font-size: 22px;
z-index: 100;
}
.toast {
position: fixed;
top: 60px;
right: 220px;
background: #4a8a4a;
color: #fff;
padding: 10px 18px;
border-radius: 6px;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.4);
z-index: 200;
opacity: 0;
transform: translateY(-10px);
transition: all 0.3s;
font-size: 13px;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.error { background: #a52a2a; }
</style>
</head>
<body>
<div id="loading">⏳ 加载 Three.js 与 3D 模型...</div>
<div id="header">
<h1>🗺️ 3D 关卡设计器</h1>
<input type="text" id="level-name" placeholder="地图名字...">
<button class="primary" id="btn-save">💾 保存</button>
<button id="btn-new">📄 新建</button>
<button id="btn-clear">🗑️ 清空</button>
<button id="btn-export">📤 导出 JSON</button>
<button id="btn-rotate">🔄 旋转选中(R)</button>
<button id="btn-delete">❌ 删除模式(X)</button>
</div>
<div id="palette"></div>
<div id="scene-container"></div>
<div id="right-panel">
<h3>📚 已保存地图</h3>
<div id="saved-list"></div>
</div>
<div id="status">点 palette 选 tile → 点地面放置 · 拖动右键转视角 · 滚轮缩放</div>
<div class="toast" id="toast"></div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// ============================================================
// 音效系统 — Web Audio API 纯代码合成,零文件依赖
// ============================================================
let audioCtx = null;
function ensureAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') audioCtx.resume();
}
function playTone(freq, duration, type = 'sine', volume = 0.15) {
ensureAudio();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.value = freq;
osc.connect(gain);
gain.connect(audioCtx.destination);
const t = audioCtx.currentTime;
gain.gain.setValueAtTime(volume, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + duration);
osc.start(t);
osc.stop(t + duration);
}
// 各种音效配方
const SFX = {
place: () => { playTone(523, 0.06, 'triangle', 0.12); setTimeout(() => playTone(659, 0.08, 'triangle', 0.10), 30); },
erase: () => { playTone(220, 0.15, 'sawtooth', 0.12); },
rotate: () => { playTone(880, 0.05, 'triangle', 0.08); },
hover: () => { playTone(1200, 0.02, 'sine', 0.04); },
select: () => { playTone(440, 0.06, 'sine', 0.10); setTimeout(() => playTone(660, 0.08, 'sine', 0.08), 40); },
save: () => {
playTone(523, 0.1, 'sine', 0.15);
setTimeout(() => playTone(659, 0.1, 'sine', 0.15), 80);
setTimeout(() => playTone(784, 0.18, 'sine', 0.15), 160);
},
load: () => { playTone(659, 0.1, 'sine', 0.12); setTimeout(() => playTone(880, 0.15, 'sine', 0.12), 80); },
error: () => { playTone(200, 0.2, 'sawtooth', 0.15); setTimeout(() => playTone(150, 0.25, 'sawtooth', 0.15), 100); },
clear: () => { playTone(330, 0.08, 'square', 0.10); setTimeout(() => playTone(220, 0.15, 'square', 0.10), 60); },
modeToggle: () => { playTone(700, 0.05, 'square', 0.08); setTimeout(() => playTone(900, 0.05, 'square', 0.08), 30); },
};
// ============================================================
// 配置
// ============================================================
const MODEL_BASE = 'assets/kenney-td-3d/Models/GLB%20format/';
const TILE_SIZE = 1.0; // 每个网格单元的世界尺寸
const GRID_SIZE = 12; // 12x12 网格
// Palette — 分类显示的 tile/塔/装饰
const PALETTE = {
'🌿 基础地形': [
{ key: 'straight', file: 'tile.glb', label: '空地草地' },
{ key: 'dirt', file: 'tile-dirt.glb', label: '泥土' },
{ key: 'tile-straight', file: 'tile-straight.glb', label: '路径(直)' },
{ key: 'tile-corner-square',file: 'tile-corner-square.glb', label: '路径(弯)' },
{ key: 'tile-crossing', file: 'tile-crossing.glb', label: '路径(十字)' },
{ key: 'tile-split', file: 'tile-split.glb', label: '路径(T 叉)' },
],
'🚪 起点 / 终点': [
{ key: 'tile-spawn', file: 'tile-spawn.glb', label: '🚪 起点 spawn' },
{ key: 'tile-spawn-end', file: 'tile-spawn-end.glb', label: '🏰 终点' },
],
'❄️ 雪地变体': [
{ key: 'snow-tile', file: 'snow-tile.glb', label: '雪地' },
{ key: 'snow-tile-straight',file: 'snow-tile-straight.glb', label: '雪路(直)' },
{ key: 'snow-tile-corner', file: 'snow-tile-corner-square.glb', label: '雪路(弯)' },
],
'🗼 塔(完整)': [
{ key: 'tower-round', file: 'tower-round-bottom-a.glb', label: '🏛️ 圆塔底' },
{ key: 'tower-square', file: 'tower-square-bottom-a.glb', label: '⬜ 方塔底' },
{ key: 'tower-crystals', file: 'tower-round-crystals.glb', label: '💎 水晶塔' },
],
'🌳 装饰': [
{ key: 'tile-tree', file: 'tile-tree.glb', label: '🌲 树' },
{ key: 'tile-tree-double', file: 'tile-tree-double.glb', label: '🌲🌲 双树' },
{ key: 'tile-rock', file: 'tile-rock.glb', label: '🪨 岩石' },
{ key: 'tile-crystal', file: 'tile-crystal.glb', label: '💎 水晶' },
{ key: 'detail-tree', file: 'detail-tree.glb', label: '🌳 小树' },
],
};
// ============================================================
// Three.js 场景
// ============================================================
const container = document.getElementById('scene-container');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb);
scene.fog = new THREE.Fog(0x87ceeb, 20, 50);
const camera = new THREE.PerspectiveCamera(
45,
container.clientWidth / container.clientHeight,
0.1, 100
);
camera.position.set(10, 12, 10);
camera.lookAt(GRID_SIZE/2, 0, GRID_SIZE/2);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
container.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(GRID_SIZE/2, 0, GRID_SIZE/2);
controls.enablePan = true;
controls.minDistance = 5;
controls.maxDistance = 30;
controls.maxPolarAngle = Math.PI / 2 - 0.05;
// 光照
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const sun = new THREE.DirectionalLight(0xffffff, 0.9);
sun.position.set(10, 20, 5);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -20;
sun.shadow.camera.right = 20;
sun.shadow.camera.top = 20;
sun.shadow.camera.bottom = -20;
scene.add(sun);
// 地面网格(辅助参考)
const gridHelper = new THREE.GridHelper(GRID_SIZE, GRID_SIZE, 0xaaaaaa, 0x666666);
gridHelper.position.set(GRID_SIZE/2, 0.01, GRID_SIZE/2);
scene.add(gridHelper);
// 透明地面(供 raycast 检测点击)
const groundGeo = new THREE.PlaneGeometry(GRID_SIZE, GRID_SIZE);
const groundMat = new THREE.MeshLambertMaterial({ color: 0x4a8a4a });
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.position.set(GRID_SIZE/2, 0, GRID_SIZE/2);
ground.receiveShadow = true;
ground.userData.isGround = true;
scene.add(ground);
// 选中高亮框(在鼠标 hover 的格子上)
const hoverGeo = new THREE.BoxGeometry(TILE_SIZE, 0.1, TILE_SIZE);
const hoverMat = new THREE.MeshBasicMaterial({
color: 0xffd700, transparent: true, opacity: 0.4
});
const hoverBox = new THREE.Mesh(hoverGeo, hoverMat);
hoverBox.visible = false;
scene.add(hoverBox);
// ============================================================
// 模型加载与缓存
// ============================================================
const loader = new GLTFLoader();
const modelCache = new Map();
async function loadModel(file) {
if (modelCache.has(file)) return modelCache.get(file).clone();
return new Promise((resolve, reject) => {
loader.load(MODEL_BASE + encodeURIComponent(file), (gltf) => {
const obj = gltf.scene;
obj.traverse((node) => {
if (node.isMesh) {
node.castShadow = true;
node.receiveShadow = true;
}
});
modelCache.set(file, obj);
resolve(obj.clone());
}, undefined, reject);
});
}
// ============================================================
// 编辑器状态
// ============================================================
let selectedTile = null; // PALETTE 里的一个 item
let placedTiles = new Map(); // gridKey "x,z" → { item, mesh, rotY }
let isDeleteMode = false;
let currentRotation = 0; // 当前旋转(弧度,0/π/2/π/3π/2)
function gridKey(x, z) { return `${x},${z}`; }
async function placeTile(gx, gz) {
if (!selectedTile) return;
const key = gridKey(gx, gz);
// 先移除已有
if (placedTiles.has(key)) {
scene.remove(placedTiles.get(key).mesh);
placedTiles.delete(key);
}
const model = await loadModel(selectedTile.file);
model.position.set(gx + 0.5, 0, gz + 0.5);
model.rotation.y = currentRotation;
scene.add(model);
placedTiles.set(key, {
item: selectedTile,
mesh: model,
rotY: currentRotation,
file: selectedTile.file,
key: selectedTile.key,
});
SFX.place();
}
function eraseTile(gx, gz) {
const key = gridKey(gx, gz);
if (placedTiles.has(key)) {
scene.remove(placedTiles.get(key).mesh);
placedTiles.delete(key);
SFX.erase();
}
}
// ============================================================
// 鼠标交互(raycast)
// ============================================================
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
function getGridFromMouse(event) {
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObject(ground);
if (hits.length === 0) return null;
const p = hits[0].point;
const gx = Math.floor(p.x);
const gz = Math.floor(p.z);
if (gx < 0 || gx >= GRID_SIZE || gz < 0 || gz >= GRID_SIZE) return null;
return { gx, gz };
}
renderer.domElement.addEventListener('mousemove', (e) => {
const g = getGridFromMouse(e);
if (g) {
hoverBox.position.set(g.gx + 0.5, 0.05, g.gz + 0.5);
hoverBox.visible = true;
hoverMat.color.setHex(isDeleteMode ? 0xff3333 : 0xffd700);
} else {
hoverBox.visible = false;
}
});
renderer.domElement.addEventListener('click', async (e) => {
if (e.button !== 0) return;
const g = getGridFromMouse(e);
if (!g) return;
if (isDeleteMode) {
eraseTile(g.gx, g.gz);
} else if (selectedTile) {
await placeTile(g.gx, g.gz);
}
});
// ============================================================
// Palette UI
// ============================================================
function renderPalette() {
const el = document.getElementById('palette');
el.innerHTML = '';
for (const [cat, items] of Object.entries(PALETTE)) {
const h = document.createElement('h3');
h.textContent = cat;
el.appendChild(h);
for (const item of items) {
const div = document.createElement('div');
div.className = 'ptile' + (selectedTile?.key === item.key ? ' selected' : '');
div.textContent = item.label;
div.addEventListener('click', () => {
selectedTile = item;
isDeleteMode = false;
renderPalette();
SFX.select();
document.getElementById('status').textContent =
`已选: ${item.label} · 点地面放置 · 按 R 旋转 · 按 X 切换删除模式`;
});
el.appendChild(div);
}
}
}
// ============================================================
// 键盘
// ============================================================
window.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return;
if (e.key === 'r' || e.key === 'R') {
currentRotation = (currentRotation + Math.PI / 2) % (Math.PI * 2);
SFX.rotate();
toast(`已旋转: ${Math.round(currentRotation * 180 / Math.PI)}°`);
}
if (e.key === 'x' || e.key === 'X') {
isDeleteMode = !isDeleteMode;
SFX.modeToggle();
document.getElementById('status').textContent =
isDeleteMode ? '🗑️ 删除模式:点格子清除' : (selectedTile ? `已选: ${selectedTile.label}` : '请选 tile');
}
});
// ============================================================
// 保存 / 加载
// ============================================================
function saveLevel() {
const name = document.getElementById('level-name').value.trim();
if (!name) { toast('请输入地图名字', true); return; }
const data = {
name,
gridSize: GRID_SIZE,
tiles: Array.from(placedTiles.entries()).map(([key, val]) => {
const [x, z] = key.split(',').map(Number);
return { x, z, key: val.key, file: val.file, rotY: val.rotY };
}),
savedAt: new Date().toISOString(),
};
const all = loadAllLevels();
all[name] = data;
localStorage.setItem('wordTD-3d-levels', JSON.stringify(all));
SFX.save();
toast(`✅ 已保存「${name}`);
renderSavedList();
}
function loadAllLevels() {
try {
return JSON.parse(localStorage.getItem('wordTD-3d-levels') || '{}');
} catch { return {}; }
}
async function loadLevel(name) {
const all = loadAllLevels();
const lvl = all[name];
if (!lvl) return;
// 清空当前
for (const [, val] of placedTiles) scene.remove(val.mesh);
placedTiles.clear();
// 加载
for (const t of lvl.tiles) {
const model = await loadModel(t.file);
model.position.set(t.x + 0.5, 0, t.z + 0.5);
model.rotation.y = t.rotY || 0;
scene.add(model);
placedTiles.set(gridKey(t.x, t.z), {
item: { key: t.key, file: t.file },
mesh: model,
rotY: t.rotY || 0,
file: t.file,
key: t.key,
});
}
document.getElementById('level-name').value = lvl.name;
SFX.load();
toast(`📂 已加载「${name}`);
}
function deleteLevel(name) {
if (!confirm(`删除「${name}」?`)) return;
const all = loadAllLevels();
delete all[name];
localStorage.setItem('wordTD-3d-levels', JSON.stringify(all));
renderSavedList();
toast(`🗑️ 已删除`);
}
function renderSavedList() {
const el = document.getElementById('saved-list');
const all = loadAllLevels();
const names = Object.keys(all).sort();
if (names.length === 0) {
el.innerHTML = '<div style="color:#666;font-size:11px;text-align:center;padding:10px 0">还没保存的地图</div>';
return;
}
el.innerHTML = '';
for (const name of names) {
const div = document.createElement('div');
div.className = 'saved-item';
const nameEl = document.createElement('span');
nameEl.className = 'name';
nameEl.textContent = name;
nameEl.addEventListener('click', () => loadLevel(name));
const btn = document.createElement('button');
btn.className = 'action';
btn.textContent = '删';
btn.addEventListener('click', () => deleteLevel(name));
div.appendChild(nameEl);
div.appendChild(btn);
el.appendChild(div);
}
}
function clearAll() {
if (!confirm('清空当前地图?')) return;
for (const [, val] of placedTiles) scene.remove(val.mesh);
placedTiles.clear();
SFX.clear();
toast('已清空');
}
function newLevel() {
clearAll();
document.getElementById('level-name').value = '';
}
function exportJSON() {
const data = {
name: document.getElementById('level-name').value || 'unnamed',
gridSize: GRID_SIZE,
tiles: Array.from(placedTiles.entries()).map(([key, val]) => {
const [x, z] = key.split(',').map(Number);
return { x, z, key: val.key, file: val.file, rotY: val.rotY };
}),
};
const json = JSON.stringify(data, null, 2);
navigator.clipboard.writeText(json).then(() => toast('📤 JSON 已复制到剪贴板'));
}
document.getElementById('btn-save').addEventListener('click', saveLevel);
document.getElementById('btn-new').addEventListener('click', newLevel);
document.getElementById('btn-clear').addEventListener('click', clearAll);
document.getElementById('btn-export').addEventListener('click', exportJSON);
document.getElementById('btn-rotate').addEventListener('click', () => {
currentRotation = (currentRotation + Math.PI / 2) % (Math.PI * 2);
toast(`已旋转: ${Math.round(currentRotation * 180 / Math.PI)}°`);
});
document.getElementById('btn-delete').addEventListener('click', () => {
isDeleteMode = !isDeleteMode;
document.getElementById('btn-delete').textContent =
isDeleteMode ? '✅ 退出删除' : '❌ 删除模式(X)';
});
// ============================================================
// Toast
// ============================================================
function toast(msg, isError = false) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast show' + (isError ? ' error' : '');
if (isError) SFX.error();
setTimeout(() => el.className = 'toast', 1800);
}
// ============================================================
// 渲染循环
// ============================================================
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
// 窗口大小响应
window.addEventListener('resize', () => {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
});
// ============================================================
// 启动
// ============================================================
renderPalette();
renderSavedList();
// 加载完成 — 移除 loading
document.getElementById('loading').style.display = 'none';
animate();
console.log('3D 关卡设计器已加载完成');
</script>
</body>
</html>

View File

@@ -0,0 +1,563 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>🗺️ 单词塔防 · 关卡设计器</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: linear-gradient(180deg, #0d1b2a 0%, #16213e 100%);
color: #eee;
font-family: -apple-system, "PingFang SC", sans-serif;
min-height: 100vh;
padding: 16px;
user-select: none;
}
h1 {
color: #ffd700;
font-size: 22px;
text-shadow: 2px 2px 0 #000;
margin-bottom: 6px;
}
.subtitle { color: #aaa; font-size: 13px; margin-bottom: 16px; }
/* 顶部工具栏 */
.toolbar {
background: rgba(255, 255, 255, 0.06);
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.toolbar input[type=text] {
padding: 8px 12px;
background: #1a1a2e;
color: #ffd700;
border: 1px solid #ffd700;
border-radius: 4px;
font-family: inherit;
font-size: 14px;
width: 200px;
}
.toolbar button {
padding: 8px 14px;
background: linear-gradient(180deg, #4a90e2 0%, #357abd 100%);
color: white;
border: 2px solid #2a5a8e;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: bold;
font-family: inherit;
}
.toolbar button:hover { filter: brightness(1.15); }
.toolbar button.primary {
background: linear-gradient(180deg, #ff7a00 0%, #cc5500 100%);
border-color: #ff9900;
}
.toolbar button.danger {
background: linear-gradient(180deg, #d9534f 0%, #a52a2a 100%);
border-color: #c0392b;
}
/* 主编辑器布局 */
.main {
display: grid;
grid-template-columns: 260px 1fr 240px;
gap: 16px;
}
/* 左:调色板 */
.palette {
background: rgba(255, 255, 255, 0.06);
padding: 12px;
border-radius: 8px;
max-height: 80vh;
overflow-y: auto;
}
.palette h3 {
color: #ffd700;
font-size: 13px;
margin: 10px 0 6px;
padding-bottom: 4px;
border-bottom: 1px solid #444;
}
.palette h3:first-child { margin-top: 0; }
.palette .tiles {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
}
.palette .ptile {
width: 100%;
aspect-ratio: 1;
background-size: cover;
cursor: pointer;
border: 2px solid transparent;
border-radius: 3px;
transition: transform 0.1s;
background-color: #333;
}
.palette .ptile:hover { transform: scale(1.15); border-color: #ffd700; }
.palette .ptile.selected {
border-color: #ff7a00;
box-shadow: 0 0 10px #ff7a00;
}
.palette .special {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 12px;
font-weight: bold;
background: linear-gradient(135deg, #4a90e2, #2a5a8e);
}
/* 中:网格 */
.canvas-area {
background: rgba(255, 255, 255, 0.06);
padding: 12px;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
}
.grid {
display: grid;
grid-template-columns: repeat(15, 48px);
grid-template-rows: repeat(8, 48px);
background: #2a3a5a;
border: 3px solid #ffd700;
border-radius: 4px;
}
.grid .cell {
width: 48px;
height: 48px;
background-size: cover;
background-color: #4a8a4a;
cursor: crosshair;
position: relative;
}
.grid .cell.path-marker::after {
content: '🛣️';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
pointer-events: none;
}
.grid .cell.start-marker::after {
content: '🚪';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
pointer-events: none;
}
.grid .cell.end-marker::after {
content: '🏰';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
pointer-events: none;
}
.grid .cell.tower-marker::after {
content: '🗼';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
pointer-events: none;
}
.help {
margin-top: 10px;
color: #888;
font-size: 12px;
text-align: center;
}
/* 右:保存列表 */
.save-panel {
background: rgba(255, 255, 255, 0.06);
padding: 12px;
border-radius: 8px;
}
.save-panel h3 {
color: #ffd700;
font-size: 13px;
margin-bottom: 8px;
}
.saved-levels {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 60vh;
overflow-y: auto;
}
.saved-item {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255, 255, 255, 0.05);
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
}
.saved-item .name { color: #ffd700; flex: 1; cursor: pointer; }
.saved-item .name:hover { color: #fff; }
.saved-item .action {
background: rgba(255, 100, 100, 0.2);
border: none;
color: #fff;
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
.toast {
position: fixed;
top: 20px;
right: 20px;
background: #4a8a4a;
color: #fff;
padding: 12px 20px;
border-radius: 6px;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.4);
z-index: 999;
opacity: 0;
transform: translateY(-10px);
transition: all 0.3s;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.error { background: #a52a2a; box-shadow: 0 0 20px rgba(255, 0, 0, 0.4); }
</style>
</head>
<body>
<h1>🗺️ 单词塔防 · 关卡设计器</h1>
<div class="subtitle">点 tile 选中 → 点格子放置 · 右键擦除回草地 · 用特殊 tile 标起点/终点/路径/塔位</div>
<div class="toolbar">
<input type="text" id="level-name" placeholder="给你的地图起个名字..." value="">
<button class="primary" onclick="saveLevel()">💾 保存关卡</button>
<button onclick="newLevel()">📄 新建</button>
<button onclick="exportJSON()">📤 导出 JSON</button>
<button onclick="importJSON()">📥 导入 JSON</button>
<button class="danger" onclick="clearAll()">🗑️ 清空</button>
</div>
<div class="main">
<!-- 左:tile palette -->
<div class="palette" id="palette"></div>
<!-- 中:网格画布 -->
<div class="canvas-area">
<div class="grid" id="grid"></div>
<div class="help">
💡 左键 = 放置选中 tile · 右键 = 擦除(恢复草地) · 选"起点/终点/路径/塔位"特殊标记定义游戏元素
</div>
</div>
<!-- 右:已保存关卡 -->
<div class="save-panel">
<h3>📚 已保存关卡</h3>
<div class="saved-levels" id="saved-levels"></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// ============================================================
// Tile 调色板配置 — 分类的 Kenney TD sprite 编号
// ============================================================
const TILE_BASE = 'assets/kenney-td/PNG/Default%20size/towerDefense_tile';
const DEFAULT_TILE = 24; // 默认草地
const PALETTE = {
'🌿 草地': [24, 42, 70],
'🟫 棕色路面': [2, 5, 6, 50, 60, 71],
'🟨 沙地': [7, 34, 50, 106, 107, 108],
'🌾 草+棕过渡(直)': [1, 23, 27, 28, 31, 47, 48, 49, 51, 52, 53, 55, 56, 57, 58],
'↪️ 弯角(草+棕)': [3, 4, 25, 26, 29, 30, 73, 74, 75, 76, 79, 80],
'🗿 塔基(空位)': [15, 38, 61, 82, 107],
'🎯 塔基(瞄准)': [18, 41, 64, 85, 110],
'🪨 装饰': [20, 21, 22, 54, 59, 118, 119],
};
// 特殊标记 — 不是 sprite,而是游戏逻辑元素
const SPECIAL_MARKERS = {
'start': { label: '🚪 起点', color: '#00ff00' },
'end': { label: '🏰 终点', color: '#ff3333' },
'path': { label: '🛣️ 路径', color: '#8b6f47' },
'tower': { label: '🗼 塔位', color: '#ffd700' },
};
// ============================================================
// 网格状态
// ============================================================
const GRID_COLS = 15;
const GRID_ROWS = 8;
let selected = { type: 'tile', value: DEFAULT_TILE }; // {type:'tile'|'special', value:tileId|'start'/'end'/'path'/'tower'}
let gridData = createEmptyGrid();
let markersData = createEmptyMarkers(); // 平行的"特殊标记"层
function createEmptyGrid() {
return Array.from({length: GRID_ROWS}, () =>
Array.from({length: GRID_COLS}, () => DEFAULT_TILE)
);
}
function createEmptyMarkers() {
return Array.from({length: GRID_ROWS}, () =>
Array.from({length: GRID_COLS}, () => null)
);
}
// ============================================================
// 渲染 Palette
// ============================================================
function renderPalette() {
const el = document.getElementById('palette');
el.innerHTML = '';
// tile 分类
for (const [cat, tiles] of Object.entries(PALETTE)) {
const h = document.createElement('h3');
h.textContent = cat;
el.appendChild(h);
const tilesDiv = document.createElement('div');
tilesDiv.className = 'tiles';
for (const tid of tiles) {
const t = document.createElement('div');
t.className = 'ptile';
t.style.backgroundImage = `url(${TILE_BASE}${String(tid).padStart(3, '0')}.png)`;
t.dataset.tid = tid;
if (selected.type === 'tile' && selected.value === tid) t.classList.add('selected');
t.addEventListener('click', () => {
selected = { type: 'tile', value: tid };
renderPalette();
});
tilesDiv.appendChild(t);
}
el.appendChild(tilesDiv);
}
// 特殊标记
const sH = document.createElement('h3');
sH.textContent = '⭐ 特殊标记';
el.appendChild(sH);
const sDiv = document.createElement('div');
sDiv.className = 'tiles';
for (const [key, def] of Object.entries(SPECIAL_MARKERS)) {
const t = document.createElement('div');
t.className = 'ptile special';
t.style.background = `linear-gradient(135deg, ${def.color}, #333)`;
t.textContent = def.label.split(' ')[0];
t.title = def.label;
if (selected.type === 'special' && selected.value === key) t.classList.add('selected');
t.addEventListener('click', () => {
selected = { type: 'special', value: key };
renderPalette();
});
sDiv.appendChild(t);
}
el.appendChild(sDiv);
}
// ============================================================
// 渲染网格
// ============================================================
function renderGrid() {
const el = document.getElementById('grid');
el.innerHTML = '';
for (let r = 0; r < GRID_ROWS; r++) {
for (let c = 0; c < GRID_COLS; c++) {
const cell = document.createElement('div');
cell.className = 'cell';
const tid = gridData[r][c];
cell.style.backgroundImage = `url(${TILE_BASE}${String(tid).padStart(3, '0')}.png)`;
// 特殊标记
const marker = markersData[r][c];
if (marker === 'start') cell.classList.add('start-marker');
else if (marker === 'end') cell.classList.add('end-marker');
else if (marker === 'path') cell.classList.add('path-marker');
else if (marker === 'tower') cell.classList.add('tower-marker');
// 点击放置
cell.addEventListener('click', () => placeAt(r, c));
cell.addEventListener('contextmenu', (e) => {
e.preventDefault();
eraseAt(r, c);
});
el.appendChild(cell);
}
}
}
function placeAt(r, c) {
if (selected.type === 'tile') {
gridData[r][c] = selected.value;
} else {
markersData[r][c] = selected.value;
}
renderGrid();
}
function eraseAt(r, c) {
gridData[r][c] = DEFAULT_TILE;
markersData[r][c] = null;
renderGrid();
}
// ============================================================
// 保存 / 加载(localStorage)
// ============================================================
function saveLevel() {
const name = document.getElementById('level-name').value.trim();
if (!name) { toast('请输入关卡名字', true); return; }
const all = loadAllLevels();
all[name] = {
name,
cols: GRID_COLS,
rows: GRID_ROWS,
tiles: gridData,
markers: markersData,
savedAt: new Date().toISOString(),
};
localStorage.setItem('wordTD-levels', JSON.stringify(all));
toast(`✅ 已保存「${name}`);
renderSavedList();
}
function loadAllLevels() {
try {
return JSON.parse(localStorage.getItem('wordTD-levels') || '{}');
} catch (e) {
return {};
}
}
function loadLevel(name) {
const all = loadAllLevels();
const lvl = all[name];
if (!lvl) return;
gridData = lvl.tiles;
markersData = lvl.markers || createEmptyMarkers();
document.getElementById('level-name').value = lvl.name;
renderGrid();
toast(`📂 已加载「${name}`);
}
function deleteLevel(name) {
if (!confirm(`确定删除「${name}」吗?`)) return;
const all = loadAllLevels();
delete all[name];
localStorage.setItem('wordTD-levels', JSON.stringify(all));
toast(`🗑️ 已删除「${name}`);
renderSavedList();
}
function renderSavedList() {
const el = document.getElementById('saved-levels');
const all = loadAllLevels();
const names = Object.keys(all).sort();
if (names.length === 0) {
el.innerHTML = '<div style="color:#666;font-size:12px;text-align:center;padding:20px 0">还没有保存的关卡<br>设计完点保存就在这里</div>';
return;
}
el.innerHTML = '';
for (const name of names) {
const item = document.createElement('div');
item.className = 'saved-item';
item.innerHTML = `
<span class="name">${name}</span>
<button class="action" onclick="deleteLevel('${name.replace(/'/g, "\\'")}')">删</button>
`;
item.querySelector('.name').addEventListener('click', () => loadLevel(name));
el.appendChild(item);
}
}
// ============================================================
// 其他工具
// ============================================================
function newLevel() {
gridData = createEmptyGrid();
markersData = createEmptyMarkers();
document.getElementById('level-name').value = '';
renderGrid();
toast('📄 新建空白关卡');
}
function clearAll() {
if (!confirm('清空当前画布?')) return;
gridData = createEmptyGrid();
markersData = createEmptyMarkers();
renderGrid();
}
function exportJSON() {
const data = {
name: document.getElementById('level-name').value || 'unnamed',
cols: GRID_COLS,
rows: GRID_ROWS,
tiles: gridData,
markers: markersData,
};
const json = JSON.stringify(data, null, 2);
navigator.clipboard.writeText(json).then(() => {
toast('📤 JSON 已复制到剪贴板');
}).catch(() => {
prompt('复制下面 JSON:', json);
});
}
function importJSON() {
const input = prompt('粘贴 JSON:');
if (!input) return;
try {
const data = JSON.parse(input);
gridData = data.tiles;
markersData = data.markers || createEmptyMarkers();
document.getElementById('level-name').value = data.name || '';
renderGrid();
toast('📥 导入成功');
} catch (e) {
toast('❌ JSON 格式错误', true);
}
}
function toast(msg, isError = false) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast show' + (isError ? ' error' : '');
setTimeout(() => el.className = 'toast', 2000);
}
// ============================================================
// 启动
// ============================================================
renderPalette();
renderGrid();
renderSavedList();
// 防止页面右键菜单
document.addEventListener('contextmenu', (e) => {
if (e.target.closest('.grid')) e.preventDefault();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Kenney TD Sprite 浏览器</title>
<style>
body {
font-family: -apple-system, "PingFang SC", sans-serif;
background: #1a1a2e;
color: #eee;
margin: 0;
padding: 20px;
}
h1 { color: #ffd700; }
.info {
background: rgba(255, 215, 0, 0.1);
padding: 12px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 14px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 8px;
}
.tile {
background: #fff;
border-radius: 4px;
padding: 6px;
text-align: center;
cursor: pointer;
transition: transform 0.1s;
position: relative;
}
.tile:hover {
transform: scale(1.5);
z-index: 10;
box-shadow: 0 0 20px rgba(255, 215, 0, 0.8);
}
.tile img {
display: block;
width: 64px;
height: 64px;
margin: 0 auto;
image-rendering: pixelated;
}
.tile .num {
color: #666;
font-size: 10px;
margin-top: 4px;
font-family: monospace;
}
.search {
margin: 10px 0;
padding: 8px 12px;
background: #2a2a4e;
color: #ffd700;
border: 1px solid #ffd700;
border-radius: 4px;
font-size: 14px;
width: 200px;
}
</style>
</head>
<body>
<h1>🔍 Kenney TD Sprite 浏览器</h1>
<div class="info">
共 299 个 sprite,64×64 像素。鼠标悬停放大查看。点击复制 tile 编号。<br>
用途:为单词塔防 demo 选取塔/怪/路径/UI 等元素。
</div>
<input class="search" id="search" placeholder="跳转编号(如 042)">
<div class="grid" id="grid"></div>
<script>
const grid = document.getElementById('grid');
for (let i = 1; i <= 299; i++) {
const id = String(i).padStart(3, '0');
const div = document.createElement('div');
div.className = 'tile';
div.id = 'tile-' + id;
div.innerHTML = `
<img src="assets/kenney-td/PNG/Default%20size/towerDefense_tile${id}.png" alt="tile${id}" loading="lazy">
<div class="num">${id}</div>
`;
div.addEventListener('click', () => {
navigator.clipboard.writeText('towerDefense_tile' + id + '.png');
const orig = div.querySelector('.num').textContent;
div.querySelector('.num').textContent = '✓ 已复制';
setTimeout(() => div.querySelector('.num').textContent = orig, 1000);
});
grid.appendChild(div);
}
document.getElementById('search').addEventListener('input', (e) => {
const v = e.target.value.padStart(3, '0');
const target = document.getElementById('tile-' + v);
if (target) {
target.scrollIntoView({behavior: 'smooth', block: 'center'});
target.style.background = '#ffd700';
setTimeout(() => target.style.background = '#fff', 1500);
}
});
</script>
</body>
</html>