Files
AICODE2026/prototype/单词塔防/game-3d.html
Rocky 1c5e72676b feat: AICODE-06 春季后半 7 课大纲 + 第 12 课教案 + prototype 工程产物
## 主要变更

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:04:54 +02:00

1318 lines
41 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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;
user-select: none;
}
/* 顶部 HUD */
#hud {
position: fixed;
top: 0; left: 0; right: 0;
height: 56px;
background: linear-gradient(180deg, rgba(13,27,42,0.95) 0%, rgba(13,27,42,0.7) 100%);
border-bottom: 2px solid #ffd700;
z-index: 10;
display: flex;
align-items: center;
padding: 0 20px;
gap: 24px;
}
#hud h1 {
color: #ffd700;
font-size: 18px;
text-shadow: 1px 1px 0 #000;
}
.hud-stat {
font-size: 16px;
font-weight: bold;
}
.stat-hp { color: #ff5577; }
.stat-gold { color: #ffd700; }
.stat-kill { color: #88ff88; }
.stat-wave { color: #88ddff; }
.phase-tag {
margin-left: auto;
background: rgba(255, 215, 0, 0.15);
color: #ffd700;
padding: 4px 14px;
border-radius: 4px;
font-size: 13px;
font-weight: bold;
}
/* 3D 场景容器 */
#scene {
position: absolute;
inset: 56px 280px 200px 240px;
}
/* 左:塔商店 */
#shop {
position: fixed;
left: 0; top: 56px;
bottom: 200px;
width: 240px;
background: rgba(13, 27, 42, 0.92);
border-right: 1px solid #444;
padding: 12px;
overflow-y: auto;
z-index: 5;
}
#shop h3 {
color: #ffd700;
font-size: 13px;
margin-bottom: 8px;
border-bottom: 1px solid #333;
padding-bottom: 4px;
}
.shop-tower {
background: linear-gradient(180deg, #4a5a3a 0%, #354525 100%);
border: 2px solid #6a7a5a;
border-radius: 8px;
padding: 10px;
margin-bottom: 8px;
cursor: pointer;
transition: transform 0.1s;
}
.shop-tower:hover:not(.disabled) {
transform: scale(1.03);
border-color: #ffd700;
}
.shop-tower.placing {
background: linear-gradient(180deg, #ff7a00 0%, #cc5500 100%);
border-color: #ffd700;
box-shadow: 0 0 20px rgba(255, 215, 0, 0.6);
}
.shop-tower.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.shop-tower .name {
color: #ffd700;
font-weight: bold;
margin-bottom: 4px;
}
.shop-tower .cost { color: #ffaa00; font-size: 13px; }
.shop-tower .desc { font-size: 11px; color: #ccc; margin-top: 3px; }
/* 右:学习面板 + 卡片栏 */
#right-panel {
position: fixed;
right: 0; top: 56px;
bottom: 200px;
width: 280px;
background: rgba(13, 27, 42, 0.92);
border-left: 1px solid #444;
padding: 12px;
overflow-y: auto;
z-index: 5;
}
#right-panel h3 {
color: #ffd700;
font-size: 13px;
margin-bottom: 8px;
border-bottom: 1px solid #333;
padding-bottom: 4px;
}
.question {
text-align: center;
background: rgba(255, 215, 0, 0.08);
padding: 12px;
border-radius: 6px;
margin-bottom: 8px;
}
.question .q-emoji { font-size: 28px; }
.question .q-zh { font-size: 22px; color: #ffd700; margin-top: 4px; }
#word-input {
width: 100%;
padding: 10px 14px;
font-size: 18px;
background: #1a1a2e;
color: #ffd700;
border: 2px solid #ffd700;
border-radius: 6px;
font-family: "Menlo", monospace;
letter-spacing: 2px;
text-align: center;
text-transform: lowercase;
outline: none;
}
#word-input:focus { box-shadow: 0 0 14px #ffd700; }
#skip-btn {
width: 100%;
margin-top: 6px;
padding: 6px;
background: rgba(255, 255, 255, 0.08);
color: #aaa;
border: 1px solid #555;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-family: inherit;
}
#skip-btn:hover { background: rgba(255, 255, 255, 0.16); color: #fff; }
/* 底部:卡片栏 */
#cards-bar {
position: fixed;
left: 240px; right: 280px;
bottom: 0;
height: 200px;
background: rgba(13, 27, 42, 0.92);
border-top: 1px solid #444;
padding: 10px;
z-index: 5;
}
#cards-bar h3 {
color: #ffd700;
font-size: 13px;
margin-bottom: 6px;
}
.cards-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: flex-start;
}
.card {
background: linear-gradient(135deg, #4a90e2 0%, #357abd 100%);
color: white;
padding: 6px 12px;
border-radius: 6px;
font-family: "Menlo", monospace;
font-size: 13px;
border: 2px solid #2a5a8e;
cursor: pointer;
transition: transform 0.1s;
min-width: 80px;
text-align: center;
}
.card:hover { transform: scale(1.06); }
.card.selected {
background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%);
color: #000;
border-color: #ff8800;
box-shadow: 0 0 12px #ffd700;
}
.card .uses { font-size: 11px; opacity: 0.85; }
.empty-cards {
color: #666;
font-size: 12px;
font-style: italic;
}
/* 开始战斗按钮 */
#battle-btn {
margin-top: 10px;
width: 100%;
padding: 14px;
background: linear-gradient(180deg, #ff7a00 0%, #cc5500 100%);
color: white;
border: 2px solid #ff9900;
border-radius: 8px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
font-family: inherit;
}
#battle-btn:disabled {
background: #555;
border-color: #777;
opacity: 0.6;
cursor: not-allowed;
}
#battle-btn:hover:not(:disabled) { filter: brightness(1.1); }
/* 提示 */
.help {
margin-top: 6px;
font-size: 11px;
color: #888;
line-height: 1.5;
}
/* 加载界面 */
#loading {
position: fixed; inset: 0;
background: #0d1b2a;
display: flex; align-items: center; justify-content: center;
color: #ffd700;
font-size: 22px;
z-index: 999;
flex-direction: column;
gap: 12px;
}
/* 游戏结束遮罩 */
#end-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.88);
display: none;
align-items: center; justify-content: center;
z-index: 100;
flex-direction: column;
}
#end-overlay.show { display: flex; }
#end-overlay h2 { color: #ffd700; font-size: 48px; margin-bottom: 16px; }
#end-overlay .stats { color: #fff; font-size: 18px; line-height: 2; margin-bottom: 24px; text-align: center; }
#end-overlay button {
padding: 14px 32px;
background: #ffd700;
color: #000;
border: none;
border-radius: 8px;
font-size: 18px;
font-weight: bold;
cursor: pointer;
}
.toast {
position: fixed;
top: 70px;
left: 50%;
transform: translateX(-50%);
background: #4a8a4a;
color: #fff;
padding: 10px 20px;
border-radius: 6px;
z-index: 50;
opacity: 0;
transition: opacity 0.3s;
font-size: 14px;
}
.toast.show { opacity: 1; }
.toast.error { background: #a52a2a; }
</style>
</head>
<body>
<div id="loading">⏳ 加载 3D 模型中...<span id="load-progress">0/0</span></div>
<div id="hud">
<h1>⚔️ 单词塔防 3D</h1>
<div class="hud-stat stat-hp" id="hud-hp">❤❤❤❤❤</div>
<div class="hud-stat stat-gold" id="hud-gold">💰 300</div>
<div class="hud-stat stat-kill" id="hud-kill">💀 0/20</div>
<div class="hud-stat stat-wave" id="hud-wave">🌊 准备中</div>
<div class="phase-tag" id="phase">🎴 准备阶段</div>
</div>
<!-- 左:塔商店 -->
<div id="shop">
<h3>🏪 塔商店(点塔买,再点地面安装)</h3>
<div id="shop-list"></div>
<div class="help">
💡 战斗中也可继续买塔/装卡。击杀获得 +10 金币。
</div>
</div>
<!-- 3D 场景 -->
<div id="scene"></div>
<!-- 右:学习面板 -->
<div id="right-panel">
<h3>📚 背单词获得卡片</h3>
<div class="question">
<div class="q-emoji" id="q-emoji">📚</div>
<div class="q-zh" id="q-zh"></div>
</div>
<input id="word-input" type="text" placeholder="请输入英文..." autofocus autocomplete="off" spellcheck="false">
<button id="skip-btn">⏭️ 跳过(不会就跳)· <span id="skip-count">0</span></button>
<button id="battle-btn" disabled>⚔️ 开始战斗(先买塔+装卡)</button>
<div class="help" style="margin-top:10px">
💡 输对英文 → 获得卡片<br>
💡 点卡片再点塔 → 装填子弹<br>
💡 战斗中输入框始终可用,可补卡
</div>
</div>
<!-- 底部:卡片栏 -->
<div id="cards-bar">
<h3>🎴 我的卡片(点卡再点塔装入)</h3>
<div class="cards-grid" id="cards-grid"></div>
</div>
<div class="toast" id="toast"></div>
<div id="end-overlay">
<h2 id="end-title">🎉 完美胜利</h2>
<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';
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.12) {
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(volume, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + duration);
osc.start(t);
osc.stop(t + duration);
} catch (e) { /* 静默失败 */ }
}
const SFX = {
cardGet: () => { playTone(523, 0.06, 'triangle', 0.15); setTimeout(() => playTone(784, 0.1, 'triangle', 0.13), 30); },
cardSel: () => playTone(440, 0.05, 'sine', 0.1),
skip: () => playTone(330, 0.08, 'sawtooth', 0.08),
buyTower: () => { playTone(659, 0.08, 'square', 0.12); setTimeout(() => playTone(880, 0.12, 'square', 0.12), 60); },
build: () => { playTone(220, 0.1, 'sine', 0.15); setTimeout(() => playTone(440, 0.15, 'sine', 0.13), 60); },
fire: () => playTone(880 + Math.random()*100, 0.04, 'triangle', 0.08),
hit: () => playTone(150, 0.08, 'sawtooth', 0.1),
freeze: () => playTone(1200, 0.1, 'sine', 0.1),
chainHit: () => playTone(1500, 0.06, 'triangle', 0.08),
kill: () => { playTone(330, 0.05, 'square', 0.1); setTimeout(() => playTone(659, 0.1, 'square', 0.1), 30); },
hurt: () => { playTone(200, 0.2, 'sawtooth', 0.15); setTimeout(() => playTone(150, 0.2, 'sawtooth', 0.15), 100); },
battleStart: () => { playTone(523, 0.1, 'sine', 0.18); setTimeout(() => playTone(659, 0.1, 'sine', 0.18), 100); setTimeout(() => playTone(784, 0.2, 'sine', 0.18), 200); },
win: () => { [523, 659, 784, 988, 1175].forEach((f, i) => setTimeout(() => playTone(f, 0.2, 'sine', 0.18), i*100)); },
lose: () => { [400, 350, 300, 250, 200].forEach((f, i) => setTimeout(() => playTone(f, 0.25, 'sawtooth', 0.18), i*150)); },
error: () => { playTone(200, 0.15, 'sawtooth', 0.15); setTimeout(() => playTone(150, 0.2, 'sawtooth', 0.15), 100); },
};
// ============================================================
// 配置
// ============================================================
const MODEL_BASE = 'assets/kenney-td-3d/Models/GLB%20format/';
// 词库:学校用品
const WORDS = [
{en:'book', zh:'书', emoji:'📚'},
{en:'pen', zh:'笔', emoji:'🖊️'},
{en:'desk', zh:'书桌', emoji:'🪑'},
{en:'bag', zh:'书包', emoji:'🎒'},
{en:'ruler', zh:'尺子', emoji:'📏'},
{en:'eraser', zh:'橡皮', emoji:'🧊'},
{en:'paper', zh:'纸', emoji:'📄'},
{en:'pencil', zh:'铅笔', emoji:'✏️'},
{en:'note', zh:'笔记本', emoji:'📓'},
{en:'school', zh:'学校', emoji:'🏫'},
{en:'class', zh:'班级', emoji:'🎓'},
{en:'teacher', zh:'老师', emoji:'👨‍🏫'},
];
// 塔商店
const TOWER_SHOP = {
cannon: { cost: 50, name: '🔫 加农塔', desc: '稳定单发,平均输出', model: 'tower-square-bottom-a.glb', weapon: 'weapon-cannon.glb', color: 0xffaaaa },
frost: { cost: 80, name: '❄️ 冰冻塔', desc: '减速怪物 3 秒', model: 'tower-round-bottom-a.glb', weapon: 'weapon-ballista.glb', color: 0xaaccff },
chain: { cost: 120, name: '⚡ 闪电塔', desc: '链击附近 2 个怪', model: 'tower-round-crystals.glb', weapon: 'weapon-turret.glb', color: 0xff88ff },
};
// 战斗参数
const BATTLE = {
monsterSpeed: 0.8, // 单位/秒(降速给玩家反应时间)
monsterFrozenSpeed: 0.3,
monsterHp: 1,
spawnInterval: 3500, // ms(出怪节奏更宽松)
totalMonsters: 15, // 总数减少,更易完整体验
towerCooldown: { cannon: 800, frost: 1000, chain: 1200 }, // 塔更频繁开火
towerRange: 5, // 单位
bulletSpeed: 14, // 单位/秒
frostDuration: 3000,
chainExtra: 2,
};
const INITIAL_HP = 5;
const INITIAL_GOLD = 300; // 够买全 3 塔(50+80+120=250),余 50 备用
const CARD_USES = 5;
// 默认地图(硬编码,后续可从 localStorage 加载)
// 路径:从 (0, 2) 走到 (7, 5)
const DEFAULT_PATH_WORLD = [
new THREE.Vector3(0.5, 0, 2.5),
new THREE.Vector3(3.5, 0, 2.5),
new THREE.Vector3(3.5, 0, 5.5),
new THREE.Vector3(7.5, 0, 5.5),
];
// ============================================================
// 游戏状态
// ============================================================
const state = {
phase: 'prepare', // prepare | battle | end
hp: INITIAL_HP,
gold: INITIAL_GOLD,
cards: [], // {id, en, zh, emoji, uses, loadedTowerIds:[]}
towers: [], // {id, type, x, z, mesh, weaponMesh, loadedCardIds:[], lastFireTime}
monsters: [], // {id, hp, mesh, hpBarMesh, pathIdx, segProgress, frozenUntil, dead}
monstersSpawned: 0,
monstersKilled: 0,
currentQuestion: WORDS[0],
cardIdCounter: 0,
monsterIdCounter: 0,
towerIdCounter: 0,
skipCount: 0,
selectedCardId: null,
placingTowerType: null,
lastSpawnTime: 0,
startTime: 0,
};
// ============================================================
// Three.js 场景
// ============================================================
const container = document.getElementById('scene');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x6cb6e8);
scene.fog = new THREE.Fog(0x6cb6e8, 18, 40);
const camera = new THREE.PerspectiveCamera(
42,
container.clientWidth / container.clientHeight,
0.1, 100
);
camera.position.set(8, 9, 11);
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(4, 0, 4);
controls.minDistance = 6;
controls.maxDistance = 25;
controls.maxPolarAngle = Math.PI / 2 - 0.05;
controls.enablePan = false;
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
const sun = new THREE.DirectionalLight(0xffffff, 0.95);
sun.position.set(8, 18, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
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(20, 20);
const groundMat = new THREE.MeshLambertMaterial({ color: 0x5ca25c });
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.position.set(4, -0.01, 4);
ground.receiveShadow = true;
ground.userData.isGround = true;
scene.add(ground);
// 鼠标 hover 高亮
const hoverBox = new THREE.Mesh(
new THREE.BoxGeometry(1, 0.05, 1),
new THREE.MeshBasicMaterial({ color: 0xffd700, transparent: true, opacity: 0.45 })
);
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(true);
return new Promise((resolve, reject) => {
loader.load(MODEL_BASE + encodeURIComponent(file), (gltf) => {
const obj = gltf.scene;
obj.traverse((n) => {
if (n.isMesh) {
n.castShadow = true;
n.receiveShadow = true;
}
});
modelCache.set(file, obj);
resolve(obj.clone(true));
}, undefined, (err) => {
console.error('加载失败', file, err);
reject(err);
});
});
}
// 预加载关键模型
const PRELOAD = [
'tile.glb', 'tile-straight.glb', 'tile-corner-square.glb',
'tile-spawn.glb', 'tile-spawn-end.glb', 'tile-tree.glb', 'tile-rock.glb',
'tower-square-bottom-a.glb', 'tower-round-bottom-a.glb', 'tower-round-crystals.glb',
'weapon-cannon.glb', 'weapon-ballista.glb', 'weapon-turret.glb',
'weapon-ammo-bullet.glb', 'weapon-ammo-cannonball.glb',
'enemy-ufo-a.glb', 'enemy-ufo-b.glb', 'enemy-ufo-c.glb',
];
async function preloadAll() {
let done = 0;
const total = PRELOAD.length;
document.getElementById('load-progress').textContent = `0/${total}`;
for (const f of PRELOAD) {
try {
await loadModel(f);
} catch (e) { console.warn('Skip:', f); }
done++;
document.getElementById('load-progress').textContent = `${done}/${total}`;
}
}
// ============================================================
// 构建默认地图
// ============================================================
async function buildDefaultMap() {
// 起点 (0, 2)
const spawn = await loadModel('tile-spawn.glb');
spawn.position.set(0.5, 0, 2.5);
scene.add(spawn);
// 路径 tile(从起点到终点的转弯路径)
const pathTiles = [
{ x: 1, z: 2, model: 'tile-straight.glb', rotY: Math.PI / 2 },
{ x: 2, z: 2, model: 'tile-straight.glb', rotY: Math.PI / 2 },
{ x: 3, z: 2, model: 'tile-corner-square.glb', rotY: 0 },
{ x: 3, z: 3, model: 'tile-straight.glb', rotY: 0 },
{ x: 3, z: 4, model: 'tile-straight.glb', rotY: 0 },
{ x: 3, z: 5, model: 'tile-corner-square.glb', rotY: Math.PI / 2 * 3 },
{ x: 4, z: 5, model: 'tile-straight.glb', rotY: Math.PI / 2 },
{ x: 5, z: 5, model: 'tile-straight.glb', rotY: Math.PI / 2 },
{ x: 6, z: 5, model: 'tile-straight.glb', rotY: Math.PI / 2 },
];
for (const t of pathTiles) {
try {
const m = await loadModel(t.model);
m.position.set(t.x + 0.5, 0, t.z + 0.5);
m.rotation.y = t.rotY;
scene.add(m);
} catch (e) {}
}
// 终点 (7, 5)
const end = await loadModel('tile-spawn-end.glb');
end.position.set(7.5, 0, 5.5);
scene.add(end);
// 装饰
const decorations = [
{ x: 0.5, z: 0.5, model: 'tile-tree.glb' },
{ x: 1.5, z: 4.5, model: 'tile-tree.glb' },
{ x: 5.5, z: 1.5, model: 'tile-tree.glb' },
{ x: 6.5, z: 3.5, model: 'tile-rock.glb' },
{ x: 0.5, z: 6.5, model: 'tile-rock.glb' },
{ x: 7.5, z: 7.5, model: 'tile-tree.glb' },
];
for (const d of decorations) {
try {
const m = await loadModel(d.model);
m.position.set(d.x, 0, d.z);
scene.add(m);
} catch (e) {}
}
}
// ============================================================
// 出题逻辑
// ============================================================
function nextQuestion() {
const usedEn = new Set(state.cards.map(c => c.en));
const pool = WORDS.filter(w => !usedEn.has(w.en));
state.currentQuestion = (pool.length > 0 ? pool : WORDS)
[Math.floor(Math.random() * (pool.length > 0 ? pool.length : WORDS.length))];
document.getElementById('q-emoji').textContent = state.currentQuestion.emoji || '';
document.getElementById('q-zh').textContent = state.currentQuestion.zh;
document.getElementById('word-input').value = '';
document.getElementById('word-input').focus();
}
function acceptCard(word) {
const card = {
id: ++state.cardIdCounter,
en: word.en, zh: word.zh, emoji: word.emoji,
uses: CARD_USES,
loadedTowerIds: [],
};
state.cards.push(card);
renderCards();
refreshHUD();
refreshBattleBtn();
SFX.cardGet();
}
// ============================================================
// 卡片栏 UI
// ============================================================
function renderCards() {
const el = document.getElementById('cards-grid');
el.innerHTML = '';
const unloaded = state.cards.filter(c => c.loadedTowerIds.length === 0);
if (unloaded.length === 0) {
el.innerHTML = '<span class="empty-cards">还没卡片 — 输入英文获得卡片</span>';
return;
}
for (const c of unloaded) {
const div = document.createElement('div');
div.className = 'card' + (state.selectedCardId === c.id ? ' selected' : '');
div.innerHTML = `${c.emoji} ${c.en}<div class="uses">×${c.uses} 发</div>`;
div.addEventListener('click', () => onCardClick(c));
el.appendChild(div);
}
}
function onCardClick(card) {
if (state.phase === 'end') return;
state.selectedCardId = state.selectedCardId === card.id ? null : card.id;
SFX.cardSel();
renderCards();
}
// ============================================================
// 塔商店 UI
// ============================================================
function renderShop() {
const el = document.getElementById('shop-list');
el.innerHTML = '';
for (const [type, def] of Object.entries(TOWER_SHOP)) {
const canAfford = state.gold >= def.cost;
const isPlacing = state.placingTowerType === type;
const div = document.createElement('div');
div.className = 'shop-tower' +
(isPlacing ? ' placing' : '') +
(!canAfford ? ' disabled' : '');
div.innerHTML = `
<div class="name">${def.name}</div>
<div class="cost">💰 ${def.cost}</div>
<div class="desc">${def.desc}</div>
`;
if (canAfford) div.addEventListener('click', () => onShopBuy(type));
el.appendChild(div);
}
}
function onShopBuy(type) {
if (state.phase === 'end') return;
const def = TOWER_SHOP[type];
if (state.gold < def.cost) return;
if (state.placingTowerType === type) {
state.placingTowerType = null;
hoverBox.material.color.setHex(0xffd700);
} else {
state.placingTowerType = type;
hoverBox.material.color.setHex(0xff7a00);
SFX.buyTower();
}
renderShop();
}
// ============================================================
// 鼠标交互 — Raycast 找网格
// ============================================================
const raycaster = new THREE.Raycaster();
const mouseVec = new THREE.Vector2();
function getGridFromMouse(event) {
const rect = renderer.domElement.getBoundingClientRect();
mouseVec.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouseVec.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouseVec, 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);
return { gx, gz, worldX: gx + 0.5, worldZ: gz + 0.5 };
}
// 路径占用集合 — 防止把塔放在路径上
const PATH_CELLS = new Set([
'0,2','1,2','2,2','3,2','3,3','3,4','3,5','4,5','5,5','6,5','7,5'
]);
function isCellAvailable(gx, gz) {
if (PATH_CELLS.has(`${gx},${gz}`)) return false;
if (state.towers.some(t => t.gx === gx && t.gz === gz)) return false;
if (gx < 0 || gx >= 8 || gz < 0 || gz >= 8) return false;
return true;
}
renderer.domElement.addEventListener('mousemove', (e) => {
const g = getGridFromMouse(e);
if (g && state.placingTowerType && isCellAvailable(g.gx, g.gz)) {
hoverBox.position.set(g.worldX, 0.05, g.worldZ);
hoverBox.visible = true;
} else {
hoverBox.visible = false;
}
});
renderer.domElement.addEventListener('click', async (e) => {
if (state.phase === 'end') return;
if (!state.placingTowerType) return;
const g = getGridFromMouse(e);
if (!g || !isCellAvailable(g.gx, g.gz)) return;
await buildTower(state.placingTowerType, g.gx, g.gz);
});
// ============================================================
// 建塔
// ============================================================
async function buildTower(type, gx, gz) {
const def = TOWER_SHOP[type];
if (state.gold < def.cost) return;
state.gold -= def.cost;
const baseModel = await loadModel(def.model);
baseModel.position.set(gx + 0.5, 0, gz + 0.5);
baseModel.traverse(n => {
if (n.isMesh && n.material) {
n.material = n.material.clone();
if (type === 'frost') n.material.color = new THREE.Color(0xaaccff);
else if (type === 'chain') n.material.color = new THREE.Color(0xee88ee);
}
});
scene.add(baseModel);
// 武器
let weaponMesh = null;
try {
weaponMesh = await loadModel(def.weapon);
weaponMesh.position.set(gx + 0.5, 0.6, gz + 0.5);
scene.add(weaponMesh);
} catch (e) {}
const tower = {
id: ++state.towerIdCounter,
type, gx, gz,
x: gx + 0.5, z: gz + 0.5,
mesh: baseModel,
weaponMesh,
loadedCardIds: [],
lastFireTime: 0,
label: null,
};
// 文字标签(显示已装卡)
const labelDiv = document.createElement('div');
labelDiv.style.cssText = 'position:absolute;color:#000;background:#666;padding:2px 6px;border-radius:3px;font-size:11px;font-weight:bold;pointer-events:none;white-space:nowrap;transform:translate(-50%,-100%);transition:none;';
labelDiv.textContent = '空';
document.body.appendChild(labelDiv);
tower.labelEl = labelDiv;
state.towers.push(tower);
state.placingTowerType = null;
hoverBox.visible = false;
SFX.build();
refreshHUD();
refreshTowerLabel(tower);
renderShop();
refreshBattleBtn();
// 加入塔可点击装卡
tower.clickZone = baseModel;
}
function refreshTowerLabel(tower) {
const active = tower.loadedCardIds
.map(id => state.cards.find(c => c.id === id))
.filter(c => c && c.uses > 0);
tower.loadedCardIds = active.map(c => c.id);
if (active.length === 0) {
tower.labelEl.textContent = '空';
tower.labelEl.style.background = '#666';
} else {
const totalUses = active.reduce((s, c) => s + c.uses, 0);
tower.labelEl.textContent = `${active.length}词·${totalUses}`;
tower.labelEl.style.background = '#ffd700';
}
}
// 塔可点击装载选中的卡
renderer.domElement.addEventListener('click', (e) => {
if (state.placingTowerType) return; // 安装模式优先
if (state.selectedCardId == null) return;
// raycast 找塔
const rect = renderer.domElement.getBoundingClientRect();
mouseVec.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouseVec.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouseVec, camera);
for (const tower of state.towers) {
const hits = raycaster.intersectObject(tower.mesh, true);
if (hits.length > 0) {
loadCardToTower(tower, state.selectedCardId);
break;
}
}
});
function loadCardToTower(tower, cardId) {
const card = state.cards.find(c => c.id === cardId);
if (!card) return;
if (card.loadedTowerIds.length > 0) return; // 已装其他塔
tower.loadedCardIds.push(card.id);
card.loadedTowerIds.push(tower.id);
refreshTowerLabel(tower);
state.selectedCardId = null;
renderCards();
refreshBattleBtn();
SFX.cardSel();
}
// ============================================================
// 开始战斗
// ============================================================
function refreshBattleBtn() {
const btn = document.getElementById('battle-btn');
if (state.phase === 'battle' || state.phase === 'end') {
btn.style.display = 'none';
return;
}
const anyLoaded = state.towers.some(t => t.loadedCardIds.length > 0);
btn.disabled = !anyLoaded;
if (state.towers.length === 0) btn.textContent = '⚔️ 开始战斗(先买塔)';
else if (!anyLoaded) btn.textContent = '⚔️ 开始战斗(给塔装卡)';
else btn.textContent = '⚔️ 开始战斗 ▶';
}
function startBattle() {
state.phase = 'battle';
state.startTime = performance.now();
state.lastSpawnTime = performance.now() - BATTLE.spawnInterval;
document.getElementById('phase').textContent = '⚔️ 战斗中';
document.getElementById('battle-btn').style.display = 'none';
SFX.battleStart();
toast('战斗开始!');
}
// ============================================================
// 怪物
// ============================================================
const UFO_MODELS = ['enemy-ufo-a.glb', 'enemy-ufo-b.glb', 'enemy-ufo-c.glb'];
async function spawnMonster() {
const model = await loadModel(UFO_MODELS[state.monstersSpawned % UFO_MODELS.length]);
model.position.copy(DEFAULT_PATH_WORLD[0]);
model.position.y = 0.5;
model.scale.setScalar(0.8);
scene.add(model);
// HP 条 (sprite plane)
const hpBarBg = new THREE.Mesh(
new THREE.PlaneGeometry(0.7, 0.1),
new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.5, depthTest: false })
);
const hpBarFg = new THREE.Mesh(
new THREE.PlaneGeometry(0.65, 0.07),
new THREE.MeshBasicMaterial({ color: 0x00ff00, depthTest: false })
);
hpBarBg.renderOrder = 999;
hpBarFg.renderOrder = 1000;
const monster = {
id: ++state.monsterIdCounter,
hp: BATTLE.monsterHp,
maxHp: BATTLE.monsterHp,
mesh: model,
hpBarBg, hpBarFg,
pathIdx: 0,
segProgress: 0,
frozenUntil: 0,
dead: false,
};
scene.add(hpBarBg);
scene.add(hpBarFg);
state.monsters.push(monster);
}
function updateMonsters(dt) {
for (let i = state.monsters.length - 1; i >= 0; i--) {
const m = state.monsters[i];
if (m.dead) continue;
const speed = (performance.now() < m.frozenUntil) ? BATTLE.monsterFrozenSpeed : BATTLE.monsterSpeed;
if (m.pathIdx >= DEFAULT_PATH_WORLD.length - 1) {
onMonsterReachEnd(m);
state.monsters.splice(i, 1);
continue;
}
const a = DEFAULT_PATH_WORLD[m.pathIdx];
const b = DEFAULT_PATH_WORLD[m.pathIdx + 1];
const segLen = a.distanceTo(b);
m.segProgress += (speed * dt / 1000) / segLen;
if (m.segProgress >= 1) {
m.pathIdx++;
m.segProgress = 0;
}
const t = m.segProgress;
m.mesh.position.x = a.x + (b.x - a.x) * t;
m.mesh.position.z = a.z + (b.z - a.z) * t;
m.mesh.position.y = 0.5 + Math.sin(performance.now()/300) * 0.1;
m.mesh.rotation.y += dt * 0.001;
// 冰冻 tint
if (performance.now() < m.frozenUntil) {
m.mesh.traverse(n => n.isMesh && n.material && (n.material.color?.setHex(0xaaccff)));
}
// HP 条跟随
m.hpBarBg.position.set(m.mesh.position.x, 1.4, m.mesh.position.z);
m.hpBarFg.position.set(m.mesh.position.x, 1.42, m.mesh.position.z);
m.hpBarBg.lookAt(camera.position);
m.hpBarFg.lookAt(camera.position);
const ratio = m.hp / m.maxHp;
m.hpBarFg.scale.x = Math.max(0, ratio);
m.hpBarFg.material.color.setHex(ratio > 0.5 ? 0x00ff00 : ratio > 0.25 ? 0xffaa00 : 0xff3333);
}
}
function onMonsterReachEnd(m) {
state.hp -= 1;
scene.remove(m.mesh);
scene.remove(m.hpBarBg);
scene.remove(m.hpBarFg);
SFX.hurt();
flashHurt();
refreshHUD();
checkEnd();
}
// ============================================================
// 塔射击
// ============================================================
function updateTowers(dt) {
const now = performance.now();
// 标记本帧已有塔瞄准的怪(避免多塔重复瞄准)
const targeted = new Set();
for (const tower of state.towers) {
const cd = BATTLE.towerCooldown[tower.type];
if (now - tower.lastFireTime < cd) continue;
// 找一张还有 uses 的卡
const card = tower.loadedCardIds
.map(id => state.cards.find(c => c.id === id))
.find(c => c && c.uses > 0);
if (!card) continue;
// 优先找还没被瞄准的活怪;若全被瞄准则也允许重复
let target = null;
let bestDist = Infinity;
for (const phase of [true, false]) { // 两轮:先未瞄准的,再所有
for (const m of state.monsters) {
if (m.dead) continue;
if (phase && targeted.has(m.id)) continue;
const dx = m.mesh.position.x - tower.x;
const dz = m.mesh.position.z - tower.z;
const d = Math.sqrt(dx*dx + dz*dz);
if (d <= BATTLE.towerRange && d < bestDist) {
bestDist = d;
target = m;
}
}
if (target) break;
}
if (!target) continue;
targeted.add(target.id);
// 开火
card.uses -= 1;
tower.lastFireTime = now;
fireBullet(tower, target);
if (card.uses === 0) {
refreshTowerLabel(tower);
renderCards();
} else {
refreshTowerLabel(tower);
}
SFX.fire();
// 塔旋转朝向目标
if (tower.weaponMesh) {
const dx = target.mesh.position.x - tower.x;
const dz = target.mesh.position.z - tower.z;
tower.weaponMesh.rotation.y = Math.atan2(dx, dz);
}
}
}
function fireBullet(tower, target) {
const colors = { cannon: 0xffd700, frost: 0xaaccff, chain: 0xff88ff };
const bullet = new THREE.Mesh(
new THREE.SphereGeometry(0.12, 8, 8),
new THREE.MeshBasicMaterial({ color: colors[tower.type] })
);
bullet.position.set(tower.x, 0.8, tower.z);
scene.add(bullet);
// 追踪目标
bullet.userData = { target, tower, fired: performance.now() };
bullets.push(bullet);
}
const bullets = [];
function updateBullets(dt) {
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
if (!b.userData.target || b.userData.target.dead) {
scene.remove(b);
bullets.splice(i, 1);
continue;
}
const tx = b.userData.target.mesh.position.x;
const ty = b.userData.target.mesh.position.y;
const tz = b.userData.target.mesh.position.z;
const dx = tx - b.position.x;
const dy = ty - b.position.y;
const dz = tz - b.position.z;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);
if (dist < 0.3) {
// 命中
applyDamage(b.userData.target, 1, b.userData.tower.type);
scene.remove(b);
bullets.splice(i, 1);
continue;
}
const step = BATTLE.bulletSpeed * dt / 1000;
const k = Math.min(step / dist, 1);
b.position.x += dx * k;
b.position.y += dy * k;
b.position.z += dz * k;
// 超时清理
if (performance.now() - b.userData.fired > 3000) {
scene.remove(b);
bullets.splice(i, 1);
}
}
}
function applyDamage(monster, damage, towerType) {
if (monster.dead) return;
monster.hp -= damage;
if (towerType === 'frost') {
monster.frozenUntil = performance.now() + BATTLE.frostDuration;
SFX.freeze();
} else {
SFX.hit();
}
if (monster.hp <= 0) {
onMonsterDeath(monster);
if (towerType === 'chain') chainAttack(monster);
} else if (towerType === 'chain') {
chainAttack(monster);
}
}
function chainAttack(initialMonster) {
const others = state.monsters
.filter(m => !m.dead && m !== initialMonster)
.map(m => {
const dx = m.mesh.position.x - initialMonster.mesh.position.x;
const dz = m.mesh.position.z - initialMonster.mesh.position.z;
return { m, d: Math.sqrt(dx*dx + dz*dz) };
})
.filter(o => o.d < 3)
.sort((a, b) => a.d - b.d)
.slice(0, BATTLE.chainExtra);
for (const { m } of others) {
if (m.dead) continue;
m.hp -= 1;
SFX.chainHit();
// 雷电线视觉
const points = [
new THREE.Vector3(initialMonster.mesh.position.x, 0.6, initialMonster.mesh.position.z),
new THREE.Vector3(m.mesh.position.x, 0.6, m.mesh.position.z),
];
const lineGeo = new THREE.BufferGeometry().setFromPoints(points);
const lineMat = new THREE.LineBasicMaterial({ color: 0xff88ff, linewidth: 2, transparent: true });
const line = new THREE.Line(lineGeo, lineMat);
scene.add(line);
setTimeout(() => scene.remove(line), 250);
if (m.hp <= 0) onMonsterDeath(m);
}
}
function onMonsterDeath(monster) {
if (monster.dead) return;
monster.dead = true;
state.monstersKilled += 1;
state.gold += 10;
scene.remove(monster.mesh);
scene.remove(monster.hpBarBg);
scene.remove(monster.hpBarFg);
SFX.kill();
refreshHUD();
renderShop();
checkEnd();
}
// ============================================================
// 胜负判定
// ============================================================
function checkEnd() {
if (state.phase !== 'battle') return;
if (state.hp <= 0) {
state.phase = 'end';
endGame(false);
} else if (state.monstersKilled >= BATTLE.totalMonsters) {
state.phase = 'end';
endGame(true);
}
}
function endGame(victory) {
const time = Math.floor((performance.now() - state.startTime) / 1000);
document.getElementById('end-title').textContent = victory ? '🏆 完美胜利' : '💀 GAME OVER';
document.getElementById('end-stats').innerHTML = `
⚔️ 击杀: <b>${state.monstersKilled}</b> / ${BATTLE.totalMonsters}<br>
💰 金币: <b>${state.gold}</b><br>
❤️ 剩余 HP: <b>${state.hp}</b><br>
🎴 总卡数: <b>${state.cards.length}</b> · 跳过 ${state.skipCount} 次<br>
⏱️ 用时: <b>${time}</b> 秒
`;
document.getElementById('end-overlay').classList.add('show');
if (victory) SFX.win(); else SFX.lose();
}
// ============================================================
// HUD
// ============================================================
function refreshHUD() {
document.getElementById('hud-hp').textContent =
'❤'.repeat(Math.max(0, state.hp)) + '🖤'.repeat(INITIAL_HP - state.hp);
document.getElementById('hud-gold').textContent = `💰 ${state.gold}`;
document.getElementById('hud-kill').textContent = `💀 ${state.monstersKilled}/${BATTLE.totalMonsters}`;
document.getElementById('hud-wave').textContent = state.phase === 'battle'
? `🌊 已出 ${state.monstersSpawned}`
: '🌊 准备中';
}
function flashHurt() {
const flash = document.createElement('div');
flash.style.cssText = 'position:fixed;inset:0;background:#f00;opacity:0.35;pointer-events:none;z-index:200;transition:opacity 0.3s;';
document.body.appendChild(flash);
setTimeout(() => flash.style.opacity = '0', 50);
setTimeout(() => flash.remove(), 400);
}
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);
}
// ============================================================
// 塔标签位置更新(每帧把 DOM 标签同步到 3D 塔上方)
// ============================================================
function updateTowerLabels() {
for (const tower of state.towers) {
if (!tower.labelEl) continue;
const v = new THREE.Vector3(tower.x, 1.6, tower.z);
v.project(camera);
const x = (v.x * 0.5 + 0.5) * container.clientWidth + 240; // +240 因为 scene 偏移
const y = (-v.y * 0.5 + 0.5) * container.clientHeight + 56;
tower.labelEl.style.left = x + 'px';
tower.labelEl.style.top = y + 'px';
}
}
// ============================================================
// 输入绑定
// ============================================================
document.getElementById('word-input').addEventListener('input', () => {
if (state.phase === 'end') return;
const val = document.getElementById('word-input').value.trim().toLowerCase();
if (val === state.currentQuestion.en) {
acceptCard(state.currentQuestion);
nextQuestion();
}
});
document.getElementById('skip-btn').addEventListener('click', () => {
if (state.phase === 'end') return;
state.skipCount += 1;
document.getElementById('skip-count').textContent = state.skipCount;
SFX.skip();
nextQuestion();
});
document.getElementById('battle-btn').addEventListener('click', startBattle);
// ============================================================
// 主循环
// ============================================================
let prevT = performance.now();
function animate() {
requestAnimationFrame(animate);
const now = performance.now();
const dt = now - prevT;
prevT = now;
controls.update();
if (state.phase === 'battle') {
// 出怪
if (state.monstersSpawned < BATTLE.totalMonsters &&
now - state.lastSpawnTime > BATTLE.spawnInterval) {
state.lastSpawnTime = now;
state.monstersSpawned++;
spawnMonster();
refreshHUD();
}
updateMonsters(dt);
updateTowers(dt);
updateBullets(dt);
}
updateTowerLabels();
renderer.render(scene, camera);
}
window.addEventListener('resize', () => {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
});
// ============================================================
// 启动
// ============================================================
// 暴露 API 用于自测(开发期保留)
window.__game = {
state, scene, camera, controls,
buildTower, spawnMonster, startBattle, nextQuestion, acceptCard,
loadCardToTower, WORDS, TOWER_SHOP, BATTLE,
};
(async function init() {
await preloadAll();
await buildDefaultMap();
document.getElementById('loading').style.display = 'none';
nextQuestion();
renderShop();
renderCards();
refreshHUD();
refreshBattleBtn();
animate();
})();
</script>
</body>
</html>