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