## 主要变更 ### 课程设计 - 大纲扩展到 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>
1318 lines
41 KiB
HTML
1318 lines
41 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>⚔️ 单词塔防 3D · 穹狼学徒</title>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
background: #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>
|