Files
Toolbox/public/tools/threshing_ground/simple.html
T
yangxiangyuan 5fb62f7900 chore: add大量静态资源文件
新增了图标、背景图、验证码素材以及音频素材等大量静态资源文件
2026-07-08 10:49:14 +08:00

2055 lines
66 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"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>打谷场 - 简单版</title>
<style>
/* ============ 全局样式 ============ */
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#1a1a2e;--panel-bg:#16213e;--border:#0f3460;
--accent:#e94560;--accent2:#0f3460;--text:#eee;--text2:#aaa;
--gold:#f0c040;--hp:#e94560;--success:#4caf50;--warn:#ff9800;
--mg-color:#4fc3f7;--arty-color:#ff7043;--at-color:#ba68c8;--aa-color:#4db6ac;
}
body{
font-family:'Segoe UI','Microsoft YaHei',sans-serif;
background:var(--bg);color:var(--text);overflow:hidden;
user-select:none;-webkit-user-select:none;height:100vh;width:100vw;
}
/* ============ 顶部状态栏 ============ */
#topbar{
position:fixed;top:0;left:0;right:0;height:40px;z-index:100;
background:var(--panel-bg);border-bottom:2px solid var(--border);
display:flex;align-items:center;padding:0 12px;gap:16px;
}
#topbar .stat{font-size:13px;white-space:nowrap}
#topbar .stat span{color:var(--accent);font-weight:bold}
#topbar .stat .gold-val{color:var(--gold)}
#topbar .stat .hp-val{color:var(--hp)}
#topbar .stat .kill-val{color:var(--success)}
#topbar .separator{width:1px;height:24px;background:var(--border)}
/* ============ 侧边栏 ============ */
#sidebar{
position:fixed;top:40px;right:0;width:220px;bottom:0;z-index:99;
background:var(--panel-bg);border-left:2px solid var(--border);
overflow-y:auto;padding:8px;
}
#sidebar h3{font-size:13px;color:var(--text2);margin:8px 0 4px;border-bottom:1px solid var(--border);padding-bottom:4px}
#sidebar .btn-group{display:flex;flex-wrap:wrap;gap:4px;margin:4px 0}
#sidebar button{
padding:5px 8px;font-size:11px;border:1px solid var(--border);
background:var(--bg);color:var(--text);border-radius:3px;cursor:pointer;
white-space:nowrap;transition:all 0.15s;
}
#sidebar button:hover{background:var(--accent2);border-color:var(--accent)}
#sidebar button.active{background:var(--accent);border-color:var(--accent);color:#fff}
#sidebar button.danger{color:var(--hp)}
#sidebar button.danger:hover{background:var(--hp);color:#fff}
#sidebar .tower-btn{display:flex;flex-direction:column;align-items:center;width:48px;padding:4px 2px;font-size:10px}
#sidebar .tower-btn .tower-icon{font-size:20px;line-height:1}
#sidebar .tower-btn .tower-cost{font-size:9px;color:var(--gold)}
#sidebar .speed-btns{display:flex;gap:2px}
#sidebar .speed-btns button{flex:1;text-align:center}
#sidebar input[type=text],#sidebar select{
width:100%;padding:4px 6px;font-size:11px;background:var(--bg);
color:var(--text);border:1px solid var(--border);border-radius:3px;margin:2px 0
}
/* ============ 画布容器 ============ */
#canvas-wrap{
position:fixed;top:40px;left:0;right:220px;bottom:0;z-index:1;
cursor:crosshair;
}
#canvas-wrap.panning{cursor:grab}
#canvas-wrap.panning:active{cursor:grabbing}
canvas{display:block;width:100%;height:100%}
/* ============ 弹窗/覆盖层 ============ */
.overlay{
position:fixed;inset:0;z-index:200;background:rgba(0,0,0,0.7);
display:flex;align-items:center;justify-content:center;
}
.overlay.hidden{display:none}
.modal{
background:var(--panel-bg);border:2px solid var(--border);
border-radius:8px;padding:20px;max-width:500px;width:90%;
max-height:80vh;overflow-y:auto;
}
.modal h2{font-size:18px;margin-bottom:12px;color:var(--accent)}
.modal p{font-size:13px;line-height:1.6;margin:6px 0;color:var(--text2)}
.modal .key{display:inline-block;background:var(--bg);border:1px solid var(--border);
padding:2px 6px;border-radius:3px;font-size:11px;margin:0 2px;color:var(--accent)}
.modal button{margin:8px 4px 0 0;padding:8px 16px;font-size:13px;
border:1px solid var(--border);background:var(--bg);color:var(--text);
border-radius:4px;cursor:pointer}
.modal button:hover{background:var(--accent);color:#fff}
.modal .btn-primary{background:var(--accent);color:#fff;border-color:var(--accent)}
/* ============ 提示toast ============ */
#toast{
position:fixed;bottom:80px;left:50%;transform:translateX(-50%);z-index:300;
background:var(--panel-bg);border:2px solid var(--accent);
color:var(--text);padding:8px 20px;border-radius:4px;font-size:13px;
opacity:0;transition:opacity 0.3s;pointer-events:none;
}
#toast.show{opacity:1}
/* ============ 底部提示栏 ============ */
#hintbar{
position:fixed;bottom:0;left:0;right:0;height:28px;z-index:100;
background:var(--panel-bg);border-top:1px solid var(--border);
display:flex;align-items:center;padding:0 12px;font-size:11px;color:var(--text2);
gap:16px;
}
/* ============ 滚动条 ============ */
::-webkit-scrollbar{width:6px}
::-webkit-scrollbar-track{background:var(--bg)}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
</style>
</head>
<body>
<!-- 顶部状态栏 -->
<div id="topbar">
<div class="stat" id="countdown-stat" style="display:none;color:#ff9800">下一波 <span id="countdown-display">3.0</span>s</div>
<div class="separator" id="countdown-sep" style="display:none"></div>
<div class="stat">波次 <span id="wave-num">0</span></div>
<div class="separator"></div>
<div class="stat">金币 <span class="gold-val" id="gold-display">500</span></div>
<div class="separator"></div>
<div class="stat">生命 <span class="hp-val" id="hp-display">20</span></div>
<div class="separator"></div>
<div class="stat">击杀 <span class="kill-val" id="kills-display">0</span></div>
<div class="separator"></div>
<div class="stat">速度 x<span id="speed-display">1</span></div>
<div class="separator"></div>
<div class="stat" id="mode-display" style="color:var(--warn)">编辑模式</div>
</div>
<!-- 侧边栏 -->
<div id="sidebar">
<h3>编辑工具</h3>
<div class="btn-group">
<button id="btn-mode-edit" class="active">编辑路线</button>
<button id="btn-mode-build">建造模式</button>
</div>
<div class="btn-group">
<button id="btn-add-path">+新路线</button>
<button id="btn-del-path" class="danger">删除路线</button>
<button id="btn-copy-path">复制路线</button>
</div>
<div class="btn-group">
<button id="btn-grid-toggle" class="active">网格吸附</button>
<button id="btn-clear-all" class="danger">清空画布</button>
</div>
<h3>建造塔</h3>
<div class="btn-group" id="tower-btns">
<button class="tower-btn" data-tower="mg">
<span class="tower-icon">🔫</span>机枪<span class="tower-cost">50</span>
</button>
<button class="tower-btn" data-tower="arty">
<span class="tower-icon">💣</span>火炮<span class="tower-cost">100</span>
</button>
<button class="tower-btn" data-tower="at">
<span class="tower-icon">🚀</span>反坦克<span class="tower-cost">150</span>
</button>
<button class="tower-btn" data-tower="aa">
<span class="tower-icon">🎯</span>防空<span class="tower-cost">120</span>
</button>
</div>
<div class="btn-group">
<button id="btn-upgrade">升级塔(+50%)</button>
<button id="btn-sell" class="danger">拆除(返50%)</button>
</div>
<h3>波次控制</h3>
<div class="btn-group">
<button id="btn-start-wave">开始下一波</button>
<button id="btn-preview-wave">预览下一波</button>
<button id="btn-auto-wave">自动下一波</button>
</div>
<h3>速度控制</h3>
<div class="speed-btns">
<button data-speed="1" class="active">x1</button>
<button data-speed="2">x2</button>
<button data-speed="3">x3</button>
</div>
<h3>存档</h3>
<div class="btn-group">
<button id="btn-save-quick">快速存档</button>
<button id="btn-load-quick">快速读档</button>
</div>
<select id="save-slot"><option value="1">存档位 1</option><option value="2">存档位 2</option><option value="3">存档位 3</option></select>
<div class="btn-group">
<button id="btn-save-slot">存入选定档位</button>
<button id="btn-load-slot">读取选定档位</button>
<button id="btn-del-slot" class="danger">删除选定档位</button>
</div>
<div class="btn-group">
<button id="btn-export-template">导出路线模板</button>
<button id="btn-import-template">导入路线模板</button>
</div>
<input type="text" id="account-name" placeholder="账号名(简易隔离)" value="player1">
<h3>系统</h3>
<div class="btn-group">
<button id="btn-pause">暂停/继续</button>
<button id="btn-reset" class="danger">重置本局</button>
<button id="btn-help">帮助</button>
<button id="btn-stats">统计</button>
</div>
</div>
<!-- 画布 -->
<div id="canvas-wrap"><canvas id="gc"></canvas></div>
<!-- 底部提示栏 -->
<div id="hintbar">
<span>空格:暂停</span><span>1-3:速度</span><span>左键:操作</span><span>右键:平移</span><span>滚轮:缩放</span><span>G:网格</span><span>N:新路线</span>
</div>
<!-- 弹窗 -->
<div id="toast"></div>
<div id="overlay-help" class="overlay hidden">
<div class="modal">
<h2>打谷场 简单版 - 操作说明</h2>
<p><b>游戏目标:</b>在空白画布上自由绘制敌军行进路线,建造军事武器塔防,消灭所有进攻敌人,保护基地!</p>
<p><b>编辑模式:</b></p>
<p>1. 点击画布添加路线节点,自动连线</p>
<p>2. 拖拽节点调整位置</p>
<p>3. 右键点击节点删除</p>
<p>4. <span class="key">N</span> 新建路线,<span class="key">G</span> 切换网格吸附</p>
<p><b>建造模式:</b></p>
<p>1. 先选武器类型,再点击路径旁空地放置</p>
<p>2. 点击已有塔可选中,再点升级/拆除</p>
<p>3. 塔不能放在路径上</p>
<p><b>快捷键:</b></p>
<p><span class="key">Space</span> 暂停/继续 &nbsp; <span class="key">1/2/3</span> 速度切换</p>
<p><span class="key">E</span> 编辑模式 &nbsp; <span class="key">B</span> 建造模式</p>
<p><span class="key">滚轮</span> 缩放 &nbsp; <span class="key">右键拖拽</span> 平移画布</p>
<p><b>兵种克制:</b></p>
<p>机枪→轻型步兵 &nbsp; 火炮→重装步兵 &nbsp; 反坦克→装甲车/坦克 &nbsp; 防空→直升机</p>
<button class="btn-primary" onclick="UI.closeOverlay('help')">知道了</button>
</div>
</div>
<div id="overlay-stats" class="overlay hidden">
<div class="modal">
<h2>本局战绩</h2>
<div id="stats-content"></div>
<button class="btn-primary" onclick="UI.closeOverlay('stats')">关闭</button>
</div>
</div>
<div id="overlay-wave-preview" class="overlay hidden">
<div class="modal">
<h2>下一波敌军预览</h2>
<div id="wave-preview-content"></div>
<button class="btn-primary" onclick="UI.closeOverlay('wave-preview')">关闭</button>
</div>
</div>
<div id="overlay-gameover" class="overlay hidden">
<div class="modal">
<h2 id="gameover-title">游戏结束</h2>
<p id="gameover-msg"></p>
<button class="btn-primary" onclick="Game.resetGame()">重新开始</button>
<button onclick="UI.closeOverlay('gameover')">关闭</button>
</div>
</div>
<script>
// ============================================================
// 打谷场 - 简单版 v1.0
// ============================================================
// ============ 游戏配置 ============
const CFG = {
// 画布世界尺寸
WORLD_W: 2400, WORLD_H: 1600,
// 网格
GRID_SIZE: 40,
// 初始资源
INIT_GOLD: 500, INIT_HP: 20,
// 塔放置的最小离路径距离
TOWER_MIN_PATH_DIST: 30,
// 每波间隔(帧)
WAVE_COOLDOWN: 120,
// BOSS 每 N 波
BOSS_INTERVAL: 10,
// 自动存档间隔(帧)
AUTOSAVE_INTERVAL: 600,
// 敌人到达终点扣血
LEAK_DMG: { light:1, heavy:2, armored:3, tank:5, heli:3, boss:10 },
// 击杀金币
KILL_GOLD: { light:10, heavy:20, armored:35, tank:50, heli:30, boss:200 },
};
// ============ 武器定义 [机枪, 火炮, 反坦克, 防空] ============
const WEAPON_DEFS = {
mg: {
name:'机枪阵地', icon:'🔫', color:'#4fc3f7',
levels:[
{ cost:50, damage:8, range:120, rof:15, desc:'M249机枪' },
{ cost:75, damage:14, range:140, rof:12, desc:'M2重机枪' },
{ cost:120, damage:22, range:160, rof:9, desc:'加特林机枪' },
],
bonus:{ light:1.5, heli:0.8 }, // 对轻型步兵1.5倍伤害,对直升机0.8倍
},
arty: {
name:'火炮阵地', icon:'💣', color:'#ff7043',
levels:[
{ cost:100, damage:25, range:160, rof:45, splash:40, desc:'迫击炮' },
{ cost:150, damage:40, range:180, rof:40, splash:55, desc:'155mm榴弹炮' },
{ cost:220, damage:65, range:210, rof:35, splash:70, desc:'火箭炮' },
],
bonus:{ heavy:1.5, light:0.6 }, // 范围伤害splash
},
at: {
name:'反坦克导弹', icon:'🚀', color:'#ba68c8',
levels:[
{ cost:150, damage:40, range:180, rof:50, desc:'AT-4火箭筒' },
{ cost:200, damage:70, range:210, rof:45, desc:'标枪导弹' },
{ cost:280, damage:110,range:240, rof:40, desc:'地狱火导弹' },
],
bonus:{ armored:1.5, tank:1.5, boss:1.3, light:0.4, heavy:0.5 },
},
aa: {
name:'防空阵地', icon:'🎯', color:'#4db6ac',
levels:[
{ cost:120, damage:6, range:180, rof:8, desc:'双联高射机枪' },
{ cost:180, damage:10, range:210, rof:6, desc:'35mm高炮' },
{ cost:250, damage:16, range:250, rof:5, desc:'防空导弹' },
],
bonus:{ heli:2.0, light:0.7, heavy:0.5 }, // 对直升机2倍伤害
}
};
// ============ 敌人定义 ============
const ENEMY_DEFS = {
light: { name:'轻型步兵', color:'#8bc34a', hp:30, speed:1.0, size:6, armor:0 },
heavy: { name:'重装步兵', color:'#ffc107', hp:80, speed:0.7, size:7, armor:2 },
armored: { name:'装甲车', color:'#78909c', hp:200, speed:0.8, size:9, armor:8 },
tank: { name:'主战坦克', color:'#5d4037', hp:500, speed:0.5, size:11, armor:20 },
heli: { name:'武装直升机', color:'#607d8b', hp:120, speed:1.3, size:8, armor:3, flying:true },
boss: { name:'装甲BOSS', color:'#f44336', hp:3000,speed:0.35,size:16, armor:40, boss:true },
};
// ============ 波次定义模板 ============
function generateWaveDef(waveNum) {
const isBoss = waveNum % CFG.BOSS_INTERVAL === 0;
const diff = Math.floor(waveNum / 5); // 难度递增
const enemies = [];
// 基础敌人
const lightCount = 3 + waveNum + diff * 2;
enemies.push({ type:'light', count:lightCount });
if (waveNum >= 3) {
enemies.push({ type:'heavy', count:2 + waveNum + diff });
}
if (waveNum >= 6) {
enemies.push({ type:'armored', count:1 + Math.floor(waveNum/2) + diff });
}
if (waveNum >= 9) {
enemies.push({ type:'tank', count:1 + Math.floor(waveNum/4) });
}
if (waveNum >= 12) {
enemies.push({ type:'heli', count:1 + Math.floor(waveNum/3) });
}
if (isBoss) {
enemies.push({ type:'boss', count:1 });
}
return enemies;
}
// ============ 游戏状态 ============
const STATE = {
mode: 'edit', // 'edit' | 'build'
gameState: 'edit', // 'edit' | 'idle' | 'battle' | 'paused' | 'win' | 'lose'
gold: CFG.INIT_GOLD,
hp: CFG.INIT_HP,
wave: 0,
kills: 0,
speed: 1,
paths: [], // [{id, name, nodes:[{x,y},...], isLoop}]
towers: [], // [{id, type, level, x, y, cooldown, targetId}]
enemies: [], // [{id, type, hp, maxHp, speed, pathIdx, pathProgress, x, y, alive}]
effects: [], // [{x, y, type, life, maxLife}]
projectiles: [], // [{x, y, tx, ty, towerId, targetId, damage, speed, color}]
selectedPathIdx: -1,
selectedTowerIdx: -1,
selectedTowerType: 'mg',
buildModeTower: 'mg',
gridSnap: true,
frameCount: 0,
autosaveTimer: 0,
waveCooldown: 0,
waveQueue: [], // 当前波待生成的敌人
waveSpawnTimer: 0,
waveSpawnInterval: 40,
nextId: 1,
// 视口
camX: 0, camY: 0, zoom: 1,
panStart: null,
// 路线编辑
editingPath: -1,
dragNode: null, // {pathIdx, nodeIdx}
// 建造
hoverTower: -1,
// 自动下一波
autoWave: false,
autoWaveTimer: 0,
};
// ============ 工具函数 ============
function dist(a,b){ return Math.sqrt((a.x-b.x)**2+(a.y-b.y)**2) }
function distXY(x1,y1,x2,y2){ return Math.sqrt((x1-x2)**2+(y1-y2)**2) }
function pointToSegDist(px,py, ax,ay, bx,by){
const dx=bx-ax, dy=by-ay;
const len2=dx*dx+dy*dy;
if(len2===0) return distXY(px,py,ax,ay);
let t=((px-ax)*dx+(py-ay)*dy)/len2;
t=Math.max(0,Math.min(1,t));
return distXY(px,py,ax+t*dx,ay+t*dy);
}
function lerp(a,b,t){ return a+(b-a)*t }
// ============ 画布渲染器 ============
const Renderer = {
canvas: null, ctx: null,
init() {
this.canvas = document.getElementById('gc');
this.ctx = this.canvas.getContext('2d');
this.resize();
},
resize() {
const wrap = document.getElementById('canvas-wrap');
this.canvas.width = wrap.clientWidth * devicePixelRatio;
this.canvas.height = wrap.clientHeight * devicePixelRatio;
this.ctx.setTransform(1,0,0,1,0,0);
},
// 世界坐标 → 屏幕坐标
worldToScreen(wx, wy) {
return {
x: (wx - STATE.camX) * STATE.zoom + this.canvas.width/2,
y: (wy - STATE.camY) * STATE.zoom + this.canvas.height/2,
};
},
// 屏幕坐标 → 世界坐标
screenToWorld(sx, sy) {
const wrap = document.getElementById('canvas-wrap');
const scaleX = this.canvas.width / wrap.clientWidth;
const scaleY = this.canvas.height / wrap.clientHeight;
return {
x: (sx * scaleX - this.canvas.width/2) / STATE.zoom + STATE.camX,
y: (sy * scaleY - this.canvas.height/2) / STATE.zoom + STATE.camY,
};
},
render() {
const ctx = this.ctx;
const w = this.canvas.width, h = this.canvas.height;
// 清屏
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, w, h);
ctx.save();
// 应用相机变换
ctx.translate(w/2, h/2);
ctx.scale(STATE.zoom, STATE.zoom);
ctx.translate(-STATE.camX, -STATE.camY);
this.drawGrid();
this.drawPaths();
this.drawTowers();
this.drawEnemies();
this.drawProjectiles();
this.drawEffects();
this.drawBuildPreview();
ctx.restore();
},
// 网格
drawGrid() {
const ctx = this.ctx;
const gs = CFG.GRID_SIZE;
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 0.5;
ctx.beginPath();
for (let x = 0; x < CFG.WORLD_W; x += gs) {
ctx.moveTo(x, 0); ctx.lineTo(x, CFG.WORLD_H);
}
for (let y = 0; y < CFG.WORLD_H; y += gs) {
ctx.moveTo(0, y); ctx.lineTo(CFG.WORLD_W, y);
}
ctx.stroke();
},
// 路径
drawPaths() {
const ctx = this.ctx;
STATE.paths.forEach((path, pi) => {
if (path.nodes.length < 2) return;
const isSel = pi === STATE.selectedPathIdx;
// 路径线
ctx.strokeStyle = isSel ? '#e94560' : '#607d8b';
ctx.lineWidth = isSel ? 4 : 3;
ctx.setLineDash(isSel ? [] : []);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(path.nodes[0].x, path.nodes[0].y);
for (let i = 1; i < path.nodes.length; i++) {
ctx.lineTo(path.nodes[i].x, path.nodes[i].y);
}
ctx.stroke();
ctx.setLineDash([]);
// 方向箭头
for (let i = 0; i < path.nodes.length - 1; i++) {
const a = path.nodes[i], b = path.nodes[i+1];
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
const ang = Math.atan2(b.y - a.y, b.x - a.x);
ctx.fillStyle = isSel ? '#e94560' : '#90a4ae';
ctx.save();
ctx.translate(mx, my);
ctx.rotate(ang);
ctx.beginPath();
ctx.moveTo(8, 0); ctx.lineTo(-4, -5); ctx.lineTo(-4, 5); ctx.closePath();
ctx.fill();
ctx.restore();
}
// 节点
path.nodes.forEach((node, ni) => {
const isStart = ni === 0;
const isEnd = ni === path.nodes.length - 1;
const r = isStart ? 8 : (isEnd ? 7 : 5);
if (isStart) {
// 出兵点 - 绿色旗标
ctx.fillStyle = '#4caf50';
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = '#fff';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('起点', node.x, node.y - 14);
} else if (isEnd) {
// 基地 - 红色旗标
ctx.fillStyle = '#e94560';
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = '#fff';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('基地', node.x, node.y - 14);
} else {
ctx.fillStyle = isSel ? '#ff7043' : '#455a64';
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
ctx.fill();
if (isSel) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.stroke();
}
}
});
});
},
// 塔
drawTowers() {
const ctx = this.ctx;
STATE.towers.forEach((tower, ti) => {
const def = WEAPON_DEFS[tower.type];
const lv = def.levels[tower.level];
const isSel = ti === STATE.selectedTowerIdx;
const isHover = ti === STATE.hoverTower;
// 攻击范围 (悬浮或选中时显示)
if (isSel || isHover) {
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.strokeStyle = def.color;
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.arc(tower.x, tower.y, lv.range, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.setLineDash([]);
}
// 塔主体 - 按等级不同形状
const size = 12 + tower.level * 3;
ctx.fillStyle = def.color;
ctx.strokeStyle = isSel ? '#fff' : '#1a1a2e';
ctx.lineWidth = isSel ? 3 : 1.5;
if (tower.type === 'mg') {
// 圆形
ctx.beginPath();
ctx.arc(tower.x, tower.y, size, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
} else if (tower.type === 'arty') {
// 正方形
ctx.fillRect(tower.x - size, tower.y - size, size * 2, size * 2);
ctx.strokeRect(tower.x - size, tower.y - size, size * 2, size * 2);
} else if (tower.type === 'at') {
// 菱形
ctx.beginPath();
ctx.moveTo(tower.x, tower.y - size);
ctx.lineTo(tower.x + size, tower.y);
ctx.lineTo(tower.x, tower.y + size);
ctx.lineTo(tower.x - size, tower.y);
ctx.closePath();
ctx.fill();
ctx.stroke();
} else if (tower.type === 'aa') {
// 三角形
ctx.beginPath();
ctx.moveTo(tower.x, tower.y - size * 1.2);
ctx.lineTo(tower.x + size, tower.y + size * 0.6);
ctx.lineTo(tower.x - size, tower.y + size * 0.6);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
// 等级标记
if (tower.level > 0) {
ctx.fillStyle = '#fff';
ctx.font = 'bold 9px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let i = 0; i <= tower.level; i++) {
ctx.fillText('★', tower.x + (i-1)*8, tower.y - size - 8);
}
}
// 冷却指示
if (tower.cooldown > 0) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.beginPath();
const pct = tower.cooldown / lv.rof;
ctx.arc(tower.x, tower.y, size + 5, -Math.PI/2, -Math.PI/2 + pct * Math.PI * 2);
ctx.stroke();
}
});
},
// 敌人
drawEnemies() {
const ctx = this.ctx;
STATE.enemies.forEach(enemy => {
if (!enemy.alive) return;
const def = ENEMY_DEFS[enemy.type];
const hpPct = enemy.hp / enemy.maxHp;
// 血条
const barW = def.size * 3;
const barH = 3;
const barY = enemy.y - def.size - 8;
ctx.fillStyle = '#333';
ctx.fillRect(enemy.x - barW/2, barY, barW, barH);
ctx.fillStyle = hpPct > 0.5 ? '#4caf50' : hpPct > 0.25 ? '#ff9800' : '#f44336';
ctx.fillRect(enemy.x - barW/2, barY, barW * hpPct, barH);
// 敌人主体
ctx.fillStyle = def.color;
ctx.strokeStyle = '#1a1a2e';
ctx.lineWidth = 1.5;
if (enemy.type === 'heli') {
// 直升机 - 十字形
ctx.beginPath();
const s = def.size;
ctx.moveTo(enemy.x, enemy.y - s); // 上 旋翼
ctx.lineTo(enemy.x + s*0.5, enemy.y);
ctx.lineTo(enemy.x, enemy.y + s*0.5); // 尾
ctx.lineTo(enemy.x - s*0.5, enemy.y);
ctx.closePath();
ctx.fill();
ctx.stroke();
// 旋翼线
ctx.strokeStyle = def.color;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(enemy.x - s*1.2, enemy.y - s*0.8);
ctx.lineTo(enemy.x + s*1.2, enemy.y - s*0.8);
ctx.stroke();
} else if (enemy.type === 'boss') {
// BOSS - 大六边形
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const a = Math.PI/6 + i * Math.PI/3;
const px = enemy.x + def.size * Math.cos(a);
const py = enemy.y + def.size * Math.sin(a);
if (i===0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
} else {
// 圆形 (步兵/装甲车/坦克)
ctx.beginPath();
ctx.arc(enemy.x, enemy.y, def.size, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// 装甲车和坦克内部标记
if (enemy.type === 'tank') {
ctx.fillStyle = '#1a1a2e';
ctx.beginPath();
ctx.arc(enemy.x, enemy.y, def.size * 0.4, 0, Math.PI * 2);
ctx.fill();
} else if (enemy.type === 'armored') {
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(enemy.x - def.size*0.5, enemy.y - def.size*0.5, def.size, def.size);
}
}
});
},
// 弹道
drawProjectiles() {
const ctx = this.ctx;
STATE.projectiles.forEach(p => {
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2);
ctx.fill();
// 尾迹
ctx.strokeStyle = p.color;
ctx.lineWidth = 1;
ctx.globalAlpha = 0.4;
ctx.beginPath();
const dx = p.tx - p.x, dy = p.ty - p.y;
const len = Math.sqrt(dx*dx+dy*dy);
if (len > 0) {
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x - dx/len * 8, p.y - dy/len * 8);
}
ctx.stroke();
ctx.globalAlpha = 1;
});
},
// 特效
drawEffects() {
const ctx = this.ctx;
STATE.effects.forEach(eff => {
const pct = eff.life / eff.maxLife;
const alpha = pct;
const radius = (1 - pct) * 30 + 5;
if (eff.type === 'explosion') {
// 爆炸圆
ctx.fillStyle = `rgba(255,100,30,${alpha*0.5})`;
ctx.beginPath();
ctx.arc(eff.x, eff.y, radius, 0, Math.PI * 2);
ctx.fill();
// 内圈
ctx.fillStyle = `rgba(255,200,50,${alpha})`;
ctx.beginPath();
ctx.arc(eff.x, eff.y, radius*0.5, 0, Math.PI * 2);
ctx.fill();
// 碎片
ctx.strokeStyle = `rgba(255,150,50,${alpha})`;
ctx.lineWidth = 1.5;
for (let i = 0; i < 6; i++) {
const a = i * Math.PI/3 + pct * 2;
ctx.beginPath();
ctx.moveTo(eff.x, eff.y);
ctx.lineTo(eff.x + Math.cos(a)*radius*1.3, eff.y + Math.sin(a)*radius*1.3);
ctx.stroke();
}
} else if (eff.type === 'hit') {
ctx.fillStyle = `rgba(255,255,200,${alpha*0.6})`;
ctx.beginPath();
ctx.arc(eff.x, eff.y, radius*0.6, 0, Math.PI * 2);
ctx.fill();
} else if (eff.type === 'muzzle') {
ctx.fillStyle = `rgba(255,200,100,${alpha*0.7})`;
ctx.beginPath();
ctx.arc(eff.x, eff.y, radius*0.4, 0, Math.PI * 2);
ctx.fill();
}
});
},
// 建造预览
drawBuildPreview() {
if (STATE.mode !== 'build' || !STATE.buildPreview) return;
const ctx = this.ctx;
const p = STATE.buildPreview;
const def = WEAPON_DEFS[STATE.buildModeTower];
const canPlace = p.canPlace;
ctx.strokeStyle = canPlace ? '#4caf50' : '#f44336';
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.arc(p.x, p.y, 15, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
if (canPlace) {
ctx.fillStyle = 'rgba(76,175,80,0.2)';
ctx.beginPath();
ctx.arc(p.x, p.y, def.levels[0].range, 0, Math.PI * 2);
ctx.fill();
}
},
};
// ============ 路径编辑器 ============
const PathEditor = {
// 获取鼠标世界坐标
getMouseWorld(e) {
const wrap = document.getElementById('canvas-wrap');
const rect = wrap.getBoundingClientRect();
return Renderer.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
},
// 吸附网格
snap(wx, wy) {
if (!STATE.gridSnap) return {x:wx, y:wy};
const gs = CFG.GRID_SIZE;
return { x: Math.round(wx/gs)*gs, y: Math.round(wy/gs)*gs };
},
// 找到最近的节点
findNearestNode(wx, wy, threshold) {
let best = null, bestDist = threshold || 20;
STATE.paths.forEach((path, pi) => {
path.nodes.forEach((node, ni) => {
const d = distXY(wx, wy, node.x, node.y);
if (d < bestDist) { bestDist = d; best = {pi, ni, node}; }
});
});
return best;
},
// 找到最近的路径段
findNearestSegment(wx, wy, threshold) {
let best = null, bestDist = threshold || 20;
STATE.paths.forEach((path, pi) => {
for (let i = 0; i < path.nodes.length - 1; i++) {
const a = path.nodes[i], b = path.nodes[i+1];
const d = pointToSegDist(wx, wy, a.x, a.y, b.x, b.y);
if (d < bestDist) { bestDist = d; best = {pi, segIdx:i, dist:d}; }
}
});
return best;
},
// 检查点是否在路径上
isOnPath(wx, wy, threshold) {
threshold = threshold || 20;
for (const path of STATE.paths) {
for (let i = 0; i < path.nodes.length - 1; i++) {
const a = path.nodes[i], b = path.nodes[i+1];
if (pointToSegDist(wx, wy, a.x, a.y, b.x, b.y) < threshold) return true;
}
}
return false;
},
// 获取点到所有路径的最小距离
minDistToAnyPath(wx, wy) {
let minD = Infinity;
for (const path of STATE.paths) {
for (let i = 0; i < path.nodes.length - 1; i++) {
const a = path.nodes[i], b = path.nodes[i+1];
const d = pointToSegDist(wx, wy, a.x, a.y, b.x, b.y);
if (d < minD) minD = d;
}
}
return minD;
},
// 添加节点到路径
addNode(pathIdx, wx, wy) {
const path = STATE.paths[pathIdx];
if (!path) return;
const s = this.snap(wx, wy);
path.nodes.push(s);
STATE.selectedPathIdx = pathIdx;
UI.showToast('节点已添加');
},
// 在路径段中插入节点
insertNode(pathIdx, segIdx, wx, wy) {
const path = STATE.paths[pathIdx];
if (!path) return;
const s = this.snap(wx, wy);
path.nodes.splice(segIdx + 1, 0, s);
STATE.selectedPathIdx = pathIdx;
UI.showToast('节点已插入');
},
// 删除节点
deleteNode(pathIdx, nodeIdx) {
const path = STATE.paths[pathIdx];
if (!path || path.nodes.length <= 2) return;
path.nodes.splice(nodeIdx, 1);
UI.showToast('节点已删除');
},
// 创建新路径
createPath() {
const id = STATE.nextId++;
STATE.paths.push({
id,
name: `路线 ${STATE.paths.length + 1}`,
nodes: [],
isLoop: false,
});
STATE.selectedPathIdx = STATE.paths.length - 1;
STATE.editingPath = STATE.paths.length - 1;
UI.showToast('新路线已创建,请在画布上打点');
},
// 删除路径
deletePath(pathIdx) {
if (STATE.paths.length <= 0) return;
// 删除该路径上的所有塔
STATE.towers = STATE.towers.filter(t => t.pathIdx !== pathIdx);
STATE.paths.splice(pathIdx, 1);
STATE.selectedPathIdx = Math.min(STATE.selectedPathIdx, STATE.paths.length - 1);
if (STATE.paths.length === 0) STATE.selectedPathIdx = -1;
UI.showToast('路线已删除');
},
// 复制路径
copyPath(pathIdx) {
const path = STATE.paths[pathIdx];
if (!path) return;
const id = STATE.nextId++;
const offsetX = 60, offsetY = 60;
const newPath = {
id,
name: `${path.name} (副本)`,
nodes: path.nodes.map(n => ({ x: n.x + offsetX, y: n.y + offsetY })),
isLoop: false,
};
STATE.paths.push(newPath);
STATE.selectedPathIdx = STATE.paths.length - 1;
UI.showToast('路线已复制');
},
};
// ============ 塔系统 ============
const TowerSystem = {
// 检查是否可以放置塔
canPlaceTower(wx, wy) {
// 检查是否在路径上
if (PathEditor.isOnPath(wx, wy, CFG.TOWER_MIN_PATH_DIST)) return false;
// 检查是否与其他塔重叠
for (const t of STATE.towers) {
if (distXY(wx, wy, t.x, t.y) < 30) return false;
}
// 检查是否在世界范围内
if (wx < 0 || wy < 0 || wx > CFG.WORLD_W || wy > CFG.WORLD_H) return false;
return true;
},
// 放置塔
placeTower(wx, wy, type) {
if (!this.canPlaceTower(wx, wy)) return false;
const def = WEAPON_DEFS[type];
const cost = def.levels[0].cost;
if (STATE.gold < cost) {
UI.showToast('金币不足!');
return false;
}
// 找到最近的路径
let minDist = Infinity, nearestPath = -1;
STATE.paths.forEach((path, pi) => {
const d = PathEditor.minDistToAnyPath.bind(PathEditor);
for (let i = 0; i < path.nodes.length - 1; i++) {
const a = path.nodes[i], b = path.nodes[i+1];
const dd = pointToSegDist(wx, wy, a.x, a.y, b.x, b.y);
if (dd < minDist) { minDist = dd; nearestPath = pi; }
}
});
STATE.gold -= cost;
STATE.towers.push({
id: STATE.nextId++,
type,
level: 0,
x: wx,
y: wy,
pathIdx: nearestPath,
cooldown: 0,
targetId: null,
});
UI.showToast(`${WEAPON_DEFS[type].name} 已建造`);
UI.updateDisplay();
return true;
},
// 升级塔
upgradeTower(towerIdx) {
const tower = STATE.towers[towerIdx];
if (!tower) return;
const def = WEAPON_DEFS[tower.type];
if (tower.level >= def.levels.length - 1) {
UI.showToast('已达最高等级!');
return;
}
const nextLv = def.levels[tower.level + 1];
const cost = Math.floor(nextLv.cost * 0.5); // 升级消耗50%新塔价格
if (STATE.gold < cost) {
UI.showToast('金币不足!');
return;
}
STATE.gold -= cost;
tower.level++;
STATE.selectedTowerIdx = towerIdx;
UI.showToast(`${def.name} 升级到 Lv${tower.level + 1}`);
UI.updateDisplay();
},
// 拆除塔
sellTower(towerIdx) {
const tower = STATE.towers[towerIdx];
if (!tower) return;
const def = WEAPON_DEFS[tower.type];
// 返还累计投入的50%
let totalInvested = 0;
for (let i = 0; i <= tower.level; i++) {
totalInvested += def.levels[i].cost;
}
const refund = Math.floor(totalInvested * 0.5);
STATE.gold += refund;
STATE.towers.splice(towerIdx, 1);
if (STATE.selectedTowerIdx === towerIdx) STATE.selectedTowerIdx = -1;
if (STATE.selectedTowerIdx >= STATE.towers.length) STATE.selectedTowerIdx = -1;
UI.showToast(`已拆除,返还 ${refund} 金币`);
UI.updateDisplay();
},
// 找到一个塔最近的敌人
findTarget(tower) {
const def = WEAPON_DEFS[tower.type];
const lv = def.levels[tower.level];
let best = null, bestDist = lv.range;
STATE.enemies.forEach(enemy => {
if (!enemy.alive) return;
const d = distXY(tower.x, tower.y, enemy.x, enemy.y);
if (d < bestDist) {
bestDist = d;
best = enemy;
}
});
return best;
},
};
// ============ 敌人系统 ============
const EnemySystem = {
// 生成一个敌人
spawnEnemy(type, pathIdx) {
const path = STATE.paths[pathIdx];
if (!path || path.nodes.length < 2) return null;
const def = ENEMY_DEFS[type];
const startNode = path.nodes[0];
const enemy = {
id: STATE.nextId++,
type,
hp: def.hp,
maxHp: def.hp,
speed: def.speed,
pathIdx,
pathProgress: 0, // 在路径上的进度 [0, 1]
x: startNode.x,
y: startNode.y,
alive: true,
armor: def.armor || 0,
flying: def.flying || false,
boss: def.boss || false,
// 内部路径参数
_segIdx: 0,
_segProgress: 0,
};
STATE.enemies.push(enemy);
return enemy;
},
// 获取路径总长度
getPathLength(path) {
let total = 0;
for (let i = 0; i < path.nodes.length - 1; i++) {
total += dist(path.nodes[i], path.nodes[i+1]);
}
return total;
},
// 获取路径上指定进度的位置
getPositionOnPath(path, progress) {
if (path.nodes.length < 2) return path.nodes[0];
progress = Math.max(0, Math.min(1, progress));
const totalLen = this.getPathLength(path);
let targetDist = progress * totalLen;
let traveled = 0;
for (let i = 0; i < path.nodes.length - 1; i++) {
const segLen = dist(path.nodes[i], path.nodes[i+1]);
if (traveled + segLen >= targetDist) {
const segProgress = (targetDist - traveled) / segLen;
return {
x: lerp(path.nodes[i].x, path.nodes[i+1].x, segProgress),
y: lerp(path.nodes[i].y, path.nodes[i+1].y, segProgress),
};
}
traveled += segLen;
}
return path.nodes[path.nodes.length - 1];
},
// 更新所有敌人
update() {
const speedFactor = STATE.speed;
STATE.enemies.forEach(enemy => {
if (!enemy.alive) return;
const path = STATE.paths[enemy.pathIdx];
if (!path || path.nodes.length < 2) { enemy.alive = false; return; }
const totalLen = this.getPathLength(path);
const speed = enemy.speed * speedFactor * 0.5; // 基础移动速度
const progressAdd = speed / totalLen;
enemy.pathProgress += progressAdd;
if (enemy.pathProgress >= 1) {
// 到达终点 - 扣血
enemy.alive = false;
const dmg = CFG.LEAK_DMG[enemy.type] || 1;
STATE.hp = Math.max(0, STATE.hp - dmg);
UI.updateDisplay();
if (STATE.hp <= 0) {
Game.onGameOver(false);
}
return;
}
// 更新位置
const pos = this.getPositionOnPath(path, enemy.pathProgress);
enemy.x = pos.x;
enemy.y = pos.y;
});
// 清理死亡敌人
STATE.enemies = STATE.enemies.filter(e => e.alive);
},
// 检查是否所有敌人已清除
allCleared() {
return STATE.enemies.every(e => !e.alive);
},
};
// ============ 游戏主循环 ============
const Game = {
init() {
Renderer.init();
UI.init();
this.setupInput();
// 初始化一条默认路线
const defaultPath = {
id: STATE.nextId++,
name: '路线 1',
nodes: [
{x:100, y:CFG.WORLD_H/2 - 200},
{x:400, y:CFG.WORLD_H/2 - 200},
{x:400, y:CFG.WORLD_H/2 + 200},
{x:800, y:CFG.WORLD_H/2 + 200},
{x:800, y:CFG.WORLD_H/2 - 100},
{x:1200, y:CFG.WORLD_H/2 - 100},
{x:1200, y:CFG.WORLD_H/2 + 300},
{x:CFG.WORLD_W - 200, y:CFG.WORLD_H/2 + 300},
{x:CFG.WORLD_W - 200, y:CFG.WORLD_H/2},
],
isLoop: false,
};
STATE.paths.push(defaultPath);
STATE.selectedPathIdx = 0;
// 尝试加载快速存档
if (SaveManager.hasQuickSave()) {
UI.showToast('发现快速存档,点击"快速读档"恢复');
}
this.loop();
},
resetGame() {
STATE.gold = CFG.INIT_GOLD;
STATE.hp = CFG.INIT_HP;
STATE.wave = 0;
STATE.kills = 0;
STATE.speed = 1;
STATE.towers = [];
STATE.enemies = [];
STATE.effects = [];
STATE.projectiles = [];
STATE.gameState = 'edit';
STATE.waveCooldown = 0;
STATE.waveQueue = [];
STATE.waveSpawnTimer = 0;
STATE.selectedTowerIdx = -1;
STATE.buildPreview = null;
STATE.autoWave = false;
STATE.autoWaveTimer = 0;
document.getElementById('btn-auto-wave').classList.remove('active');
UI.closeOverlay('gameover');
UI.updateDisplay();
UI.showToast('游戏已重置');
},
clearAll() {
STATE.paths = [];
STATE.towers = [];
STATE.enemies = [];
STATE.effects = [];
STATE.projectiles = [];
STATE.selectedPathIdx = -1;
STATE.selectedTowerIdx = -1;
STATE.gameState = 'edit';
STATE.wave = 0;
STATE.waveQueue = [];
UI.showToast('画布已清空,请绘制新路线');
UI.updateDisplay();
},
onGameOver(won) {
STATE.gameState = won ? 'win' : 'lose';
const overlay = document.getElementById('overlay-gameover');
const title = document.getElementById('gameover-title');
const msg = document.getElementById('gameover-msg');
overlay.classList.remove('hidden');
title.textContent = won ? '胜利!' : '基地失守!';
msg.textContent = won
? `恭喜!你成功守住了基地!\n波次: ${STATE.wave} | 击杀: ${STATE.kills} | 剩余金币: ${STATE.gold}`
: `敌军突破了防线...\n坚持了 ${STATE.wave} 波 | 击杀: ${STATE.kills} | 剩余金币: ${STATE.gold}`;
},
startWave() {
if (STATE.paths.length === 0) {
UI.showToast('请先绘制至少一条路线!');
return;
}
STATE.wave++;
const waveDef = generateWaveDef(STATE.wave);
STATE.waveQueue = [];
waveDef.forEach(group => {
for (let i = 0; i < group.count; i++) {
// 随机分配到路径
const pathIdx = Math.floor(Math.random() * STATE.paths.length);
STATE.waveQueue.push({ type:group.type, pathIdx });
}
});
STATE.waveSpawnTimer = 0;
STATE.waveSpawnInterval = Math.max(15, 40 - STATE.wave * 2);
STATE.gameState = 'battle';
UI.showToast(`第 ${STATE.wave} 波开始!`);
UI.updateDisplay();
},
updateWaveSpawning() {
if (STATE.waveQueue.length === 0) return;
STATE.waveSpawnTimer++;
if (STATE.waveSpawnTimer >= STATE.waveSpawnInterval) {
STATE.waveSpawnTimer = 0;
const entry = STATE.waveQueue.shift();
EnemySystem.spawnEnemy(entry.type, entry.pathIdx);
}
},
// 更新炮塔攻击
updateTowers() {
STATE.towers.forEach(tower => {
if (tower.cooldown > 0) {
tower.cooldown--;
return;
}
const target = TowerSystem.findTarget(tower);
if (!target) return;
const def = WEAPON_DEFS[tower.type];
const lv = def.levels[tower.level];
// 开火
tower.cooldown = lv.rof;
tower.targetId = target.id;
// 计算伤害(考虑护甲和克制)
let damage = lv.damage;
const bonus = def.bonus[target.type] || 1.0;
damage *= bonus;
// 护甲减伤
damage = Math.max(1, damage - target.armor * 0.5);
// 创建弹道
STATE.projectiles.push({
x: tower.x, y: tower.y,
tx: target.x, ty: target.y,
towerId: tower.id, targetId: target.id,
damage, speed: 8,
color: def.color,
splash: lv.splash || 0,
});
// 炮口特效
STATE.effects.push({
x: tower.x, y: tower.y,
type: 'muzzle', life: 8, maxLife: 8,
});
});
},
// 更新弹道
updateProjectiles() {
STATE.projectiles = STATE.projectiles.filter(p => {
const dx = p.tx - p.x, dy = p.ty - p.y;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < p.speed * 2) {
// 命中
const target = STATE.enemies.find(e => e.id === p.targetId);
if (target && target.alive) {
target.hp -= p.damage;
// 命中特效
STATE.effects.push({
x: target.x, y: target.y,
type: 'hit', life: 10, maxLife: 10,
});
// 范围伤害 (火炮)
if (p.splash > 0) {
STATE.effects.push({
x: target.x, y: target.y,
type: 'explosion', life: 15, maxLife: 15,
});
STATE.enemies.forEach(e => {
if (!e.alive || e.id === target.id) return;
const d = distXY(target.x, target.y, e.x, e.y);
if (d < p.splash) {
const splashDmg = p.damage * 0.4 * (1 - d / p.splash);
e.hp -= Math.max(1, splashDmg);
}
});
}
if (target.hp <= 0) {
target.alive = false;
STATE.kills++;
const gold = CFG.KILL_GOLD[target.type] || 10;
STATE.gold += gold;
STATE.effects.push({
x: target.x, y: target.y,
type: 'explosion', life: 20, maxLife: 20,
});
UI.updateDisplay();
}
}
return false;
}
// 移动弹道
const ratio = p.speed / dist;
p.x += dx * ratio;
p.y += dy * ratio;
return true;
});
},
// 更新特效
updateEffects() {
STATE.effects = STATE.effects.filter(eff => {
eff.life--;
return eff.life > 0;
});
},
// 检查波次是否结束
checkWaveEnd() {
if (STATE.waveQueue.length === 0 && STATE.enemies.every(e => !e.alive)) {
if (STATE.gameState === 'battle') {
if (STATE.autoWave) {
// 自动下一波:倒计时最多3秒后开始
STATE.autoWaveTimer++;
const remain = Math.max(0, (180 - STATE.autoWaveTimer) / 60);
document.getElementById('countdown-display').textContent = remain.toFixed(1);
document.getElementById('countdown-stat').style.display = '';
document.getElementById('countdown-sep').style.display = '';
if (STATE.autoWaveTimer >= 180) {
STATE.autoWaveTimer = 0;
document.getElementById('countdown-stat').style.display = 'none';
document.getElementById('countdown-sep').style.display = 'none';
this.startWave();
}
} else {
STATE.gameState = 'idle';
UI.showToast(`第 ${STATE.wave} 波结束!准备下一波`);
UI.updateDisplay();
}
}
}
},
loop() {
// 更新逻辑
if (STATE.gameState === 'battle') {
this.updateWaveSpawning();
EnemySystem.update();
this.updateTowers();
this.updateProjectiles();
this.updateEffects();
this.checkWaveEnd();
}
STATE.frameCount++;
// 自动存档
STATE.autosaveTimer++;
if (STATE.autosaveTimer >= CFG.AUTOSAVE_INTERVAL) {
STATE.autosaveTimer = 0;
SaveManager.autoSave();
}
// 渲染
Renderer.render();
requestAnimationFrame(() => this.loop());
},
setupInput() {
const wrap = document.getElementById('canvas-wrap');
// 鼠标移动
wrap.addEventListener('mousemove', (e) => {
const world = PathEditor.getMouseWorld(e);
if (STATE.panStart && e.buttons === 2) {
const dx = (e.clientX - STATE.panStart.sx) / STATE.zoom;
const dy = (e.clientY - STATE.panStart.sy) / STATE.zoom;
STATE.camX = STATE.panStart.cx - dx;
STATE.camY = STATE.panStart.cy - dy;
return;
}
if (STATE.mode === 'edit' && STATE.dragNode) {
const s = PathEditor.snap(world.x, world.y);
const path = STATE.paths[STATE.dragNode.pi];
if (path) {
path.nodes[STATE.dragNode.ni] = s;
}
return;
}
if (STATE.mode === 'build') {
const s = PathEditor.snap(world.x, world.y);
const canPlace = TowerSystem.canPlaceTower(s.x, s.y);
STATE.buildPreview = { x: s.x, y: s.y, canPlace };
// 检测悬浮塔
STATE.hoverTower = -1;
for (let i = STATE.towers.length - 1; i >= 0; i--) {
const t = STATE.towers[i];
if (distXY(world.x, world.y, t.x, t.y) < 20) {
STATE.hoverTower = i;
break;
}
}
}
});
// 鼠标按下
wrap.addEventListener('mousedown', (e) => {
e.preventDefault();
const world = PathEditor.getMouseWorld(e);
// 右键 - 平移
if (e.button === 2) {
STATE.panStart = {
sx: e.clientX, sy: e.clientY,
cx: STATE.camX, cy: STATE.camY,
};
wrap.classList.add('panning');
return;
}
// 左键
if (e.button === 0) {
if (STATE.mode === 'edit') {
// 检查是否点击了节点
const nearNode = PathEditor.findNearestNode(world.x, world.y, 15);
if (nearNode) {
// 开始拖拽
STATE.dragNode = { pi: nearNode.pi, ni: nearNode.ni };
STATE.selectedPathIdx = nearNode.pi;
return;
}
// 检查是否点击了路径段(插入节点)
const nearSeg = PathEditor.findNearestSegment(world.x, world.y, 15);
if (nearSeg) {
PathEditor.insertNode(nearSeg.pi, nearSeg.segIdx, world.x, world.y);
return;
}
// 添加到当前编辑路径
if (STATE.editingPath >= 0 && STATE.editingPath < STATE.paths.length) {
PathEditor.addNode(STATE.editingPath, world.x, world.y);
} else if (STATE.selectedPathIdx >= 0) {
PathEditor.addNode(STATE.selectedPathIdx, world.x, world.y);
}
} else if (STATE.mode === 'build') {
// 先检查是否点击了塔
let clickedTower = -1;
for (let i = STATE.towers.length - 1; i >= 0; i--) {
const t = STATE.towers[i];
if (distXY(world.x, world.y, t.x, t.y) < 20) {
clickedTower = i;
break;
}
}
if (clickedTower >= 0) {
STATE.selectedTowerIdx = clickedTower;
UI.showToast(`已选中 ${WEAPON_DEFS[STATE.towers[clickedTower].type].name} Lv${STATE.towers[clickedTower].level + 1}`);
} else {
// 放置塔
const s = PathEditor.snap(world.x, world.y);
TowerSystem.placeTower(s.x, s.y, STATE.buildModeTower);
}
}
}
});
// 鼠标释放
window.addEventListener('mouseup', (e) => {
if (e.button === 2) {
STATE.panStart = null;
wrap.classList.remove('panning');
}
if (STATE.dragNode) {
STATE.dragNode = null;
}
});
// 滚轮缩放
wrap.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomStep = 0.1;
const oldZoom = STATE.zoom;
if (e.deltaY < 0) {
STATE.zoom = Math.min(3, STATE.zoom + zoomStep);
} else {
STATE.zoom = Math.max(0.3, STATE.zoom - zoomStep);
}
// 以鼠标位置为中心缩放
const world = PathEditor.getMouseWorld(e);
const ratio = STATE.zoom / oldZoom;
STATE.camX = world.x - (world.x - STATE.camX) * ratio;
STATE.camY = world.y - (world.y - STATE.camY) * ratio;
});
// 右键菜单禁用
wrap.addEventListener('contextmenu', e => e.preventDefault());
// 键盘
window.addEventListener('keydown', (e) => {
// 如果焦点在输入框,不处理
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
switch (e.key) {
case ' ':
e.preventDefault();
UI.togglePause();
break;
case '1':
STATE.speed = 1;
UI.updateSpeedButtons();
UI.showToast('速度: x1');
break;
case '2':
STATE.speed = 2;
UI.updateSpeedButtons();
UI.showToast('速度: x2');
break;
case '3':
STATE.speed = 3;
UI.updateSpeedButtons();
UI.showToast('速度: x3');
break;
case 'g':
case 'G':
STATE.gridSnap = !STATE.gridSnap;
document.getElementById('btn-grid-toggle').classList.toggle('active', STATE.gridSnap);
UI.showToast(STATE.gridSnap ? '网格吸附: 开' : '网格吸附: 关');
break;
case 'e':
case 'E':
UI.setMode('edit');
break;
case 'b':
case 'B':
UI.setMode('build');
break;
case 'n':
case 'N':
PathEditor.createPath();
break;
case 'u':
case 'U':
if (STATE.selectedTowerIdx >= 0) {
TowerSystem.upgradeTower(STATE.selectedTowerIdx);
}
break;
case 'Delete':
if (STATE.mode === 'edit' && STATE.selectedPathIdx >= 0) {
PathEditor.deletePath(STATE.selectedPathIdx);
} else if (STATE.mode === 'build' && STATE.selectedTowerIdx >= 0) {
TowerSystem.sellTower(STATE.selectedTowerIdx);
}
break;
}
});
// 窗口大小调整
window.addEventListener('resize', () => Renderer.resize());
},
};
// ============ 存档系统 ============
const SaveManager = {
STORAGE_PREFIX: 'threshing_ground_',
getAccount() {
const input = document.getElementById('account-name');
return (input && input.value.trim()) || 'player1';
},
getSaveKey(slot) {
return `${this.STORAGE_PREFIX}${this.getAccount()}_slot_${slot}`;
},
getQuickKey() {
return `${this.STORAGE_PREFIX}${this.getAccount()}_quick`;
},
getAutoKey() {
return `${this.STORAGE_PREFIX}${this.getAccount()}_auto`;
},
getTemplateKey() {
return `${this.STORAGE_PREFIX}${this.getAccount()}_templates`;
},
serialize() {
return JSON.stringify({
gold: STATE.gold,
hp: STATE.hp,
wave: STATE.wave,
kills: STATE.kills,
paths: STATE.paths,
towers: STATE.towers,
gameState: STATE.gameState,
autoWave: STATE.autoWave,
timestamp: Date.now(),
});
},
deserialize(data) {
const d = JSON.parse(data);
STATE.gold = d.gold;
STATE.hp = d.hp;
STATE.wave = d.wave;
STATE.kills = d.kills;
STATE.paths = d.paths;
STATE.towers = d.towers;
STATE.gameState = 'paused'; // 读档后自动暂停
STATE.autoWave = d.autoWave || false;
document.getElementById('btn-auto-wave').classList.toggle('active', STATE.autoWave);
STATE.enemies = [];
STATE.effects = [];
STATE.projectiles = [];
STATE.waveQueue = [];
STATE.selectedPathIdx = STATE.paths.length > 0 ? 0 : -1;
STATE.selectedTowerIdx = -1;
UI.updateDisplay();
UI.showToast('存档已读取,游戏已暂停');
},
// 快速存档
quickSave() {
try {
localStorage.setItem(this.getQuickKey(), this.serialize());
UI.showToast('快速存档成功');
} catch(e) {
UI.showToast('存档失败:存储空间不足');
}
},
// 快速读档
quickLoad() {
const data = localStorage.getItem(this.getQuickKey());
if (!data) { UI.showToast('没有快速存档'); return; }
this.deserialize(data);
},
hasQuickSave() {
return !!localStorage.getItem(this.getQuickKey());
},
// 自动存档
autoSave() {
try {
localStorage.setItem(this.getAutoKey(), this.serialize());
} catch(e) {}
},
// 档位存档
saveToSlot(slot) {
try {
localStorage.setItem(this.getSaveKey(slot), this.serialize());
UI.showToast(`已保存到档位 ${slot}`);
} catch(e) {
UI.showToast('存档失败:存储空间不足');
}
},
// 档位读档
loadFromSlot(slot) {
const data = localStorage.getItem(this.getSaveKey(slot));
if (!data) { UI.showToast(`档位 ${slot} 为空`); return; }
this.deserialize(data);
},
// 删除档位
deleteSlot(slot) {
localStorage.removeItem(this.getSaveKey(slot));
UI.showToast(`档位 ${slot} 已删除`);
},
// 导出路线模板
exportTemplate() {
const template = {
paths: STATE.paths.map(p => ({
name: p.name,
nodes: p.nodes.map(n => ({x:n.x, y:n.y})),
})),
towers: STATE.towers.map(t => ({
type: t.type,
level: t.level,
x: t.x,
y: t.y,
})),
};
const json = JSON.stringify(template, null, 2);
const blob = new Blob([json], {type:'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `打谷场_路线模板_${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
UI.showToast('路线模板已导出');
},
// 导入路线模板
importTemplate(callback) {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const template = JSON.parse(ev.target.result);
if (template.paths) {
// 合并路径
template.paths.forEach(p => {
STATE.paths.push({
id: STATE.nextId++,
name: p.name || '导入路线',
nodes: p.nodes,
isLoop: false,
});
});
// 合并塔
if (template.towers) {
template.towers.forEach(t => {
STATE.towers.push({
id: STATE.nextId++,
type: t.type,
level: t.level || 0,
x: t.x,
y: t.y,
pathIdx: -1,
cooldown: 0,
targetId: null,
});
});
}
STATE.selectedPathIdx = STATE.paths.length - 1;
UI.showToast('路线模板已导入');
}
} catch(err) {
UI.showToast('模板格式错误');
}
};
reader.readAsText(file);
};
input.click();
},
};
// ============ UI ============
const UI = {
toastTimer: null,
init() {
this.bindButtons();
this.updateDisplay();
this.updateSpeedButtons();
},
bindButtons() {
// 模式切换
document.getElementById('btn-mode-edit').addEventListener('click', () => this.setMode('edit'));
document.getElementById('btn-mode-build').addEventListener('click', () => this.setMode('build'));
// 路线编辑
document.getElementById('btn-add-path').addEventListener('click', () => PathEditor.createPath());
document.getElementById('btn-del-path').addEventListener('click', () => {
if (STATE.selectedPathIdx >= 0) PathEditor.deletePath(STATE.selectedPathIdx);
});
document.getElementById('btn-copy-path').addEventListener('click', () => {
if (STATE.selectedPathIdx >= 0) PathEditor.copyPath(STATE.selectedPathIdx);
});
document.getElementById('btn-grid-toggle').addEventListener('click', function() {
STATE.gridSnap = !STATE.gridSnap;
this.classList.toggle('active', STATE.gridSnap);
});
document.getElementById('btn-clear-all').addEventListener('click', () => {
if (confirm('确定清空画布?所有路线和塔都会删除!')) Game.clearAll();
});
// 塔按钮
document.querySelectorAll('#tower-btns [data-tower]').forEach(btn => {
btn.addEventListener('click', () => {
STATE.buildModeTower = btn.dataset.tower;
document.querySelectorAll('#tower-btns [data-tower]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
if (STATE.mode !== 'build') this.setMode('build');
});
});
// 默认选中机枪
document.querySelector('#tower-btns [data-tower="mg"]').classList.add('active');
// 升级/拆除
document.getElementById('btn-upgrade').addEventListener('click', () => {
if (STATE.selectedTowerIdx >= 0) TowerSystem.upgradeTower(STATE.selectedTowerIdx);
else UI.showToast('请先在建造模式下点击选择一座塔');
});
document.getElementById('btn-sell').addEventListener('click', () => {
if (STATE.selectedTowerIdx >= 0) TowerSystem.sellTower(STATE.selectedTowerIdx);
else UI.showToast('请先在建造模式下点击选择一座塔');
});
// 波次
document.getElementById('btn-start-wave').addEventListener('click', () => {
if (STATE.gameState === 'battle') { UI.showToast('当前波次仍在进行中'); return; }
if (STATE.paths.length === 0) { UI.showToast('请先绘制路线!'); return; }
Game.startWave();
});
document.getElementById('btn-auto-wave').addEventListener('click', function() {
STATE.autoWave = !STATE.autoWave;
this.classList.toggle('active', STATE.autoWave);
UI.showToast(STATE.autoWave ? '自动下一波: 开' : '自动下一波: 关');
});
document.getElementById('btn-preview-wave').addEventListener('click', () => {
const nextWave = STATE.wave + 1;
const waveDef = generateWaveDef(nextWave);
const content = document.getElementById('wave-preview-content');
let html = `<p><b>第 ${nextWave} 波</b> ${nextWave % CFG.BOSS_INTERVAL === 0 ? '(BOSS波!)' : ''}</p><ul>`;
waveDef.forEach(g => {
const def = ENEMY_DEFS[g.type];
html += `<li>${def.name} x${g.count} (HP:${def.hp}, 速度:${def.speed})</li>`;
});
html += '</ul>';
content.innerHTML = html;
this.openOverlay('wave-preview');
});
// 速度
document.querySelectorAll('.speed-btns button').forEach(btn => {
btn.addEventListener('click', () => {
STATE.speed = parseInt(btn.dataset.speed);
this.updateSpeedButtons();
});
});
// 存档
document.getElementById('btn-save-quick').addEventListener('click', () => SaveManager.quickSave());
document.getElementById('btn-load-quick').addEventListener('click', () => SaveManager.quickLoad());
document.getElementById('btn-save-slot').addEventListener('click', () => {
const slot = document.getElementById('save-slot').value;
SaveManager.saveToSlot(slot);
});
document.getElementById('btn-load-slot').addEventListener('click', () => {
const slot = document.getElementById('save-slot').value;
SaveManager.loadFromSlot(slot);
});
document.getElementById('btn-del-slot').addEventListener('click', () => {
const slot = document.getElementById('save-slot').value;
if (confirm(`确定删除档位 ${slot}?`)) SaveManager.deleteSlot(slot);
});
document.getElementById('btn-export-template').addEventListener('click', () => SaveManager.exportTemplate());
document.getElementById('btn-import-template').addEventListener('click', () => SaveManager.importTemplate());
// 账号名变更
document.getElementById('account-name').addEventListener('change', () => {
UI.showToast(`账号已切换为: ${SaveManager.getAccount()}`);
});
// 系统
document.getElementById('btn-pause').addEventListener('click', () => this.togglePause());
document.getElementById('btn-reset').addEventListener('click', () => {
if (confirm('确定重置本局?所有进度将丢失!')) Game.resetGame();
});
document.getElementById('btn-help').addEventListener('click', () => this.openOverlay('help'));
document.getElementById('btn-stats').addEventListener('click', () => this.showStats());
},
setMode(mode) {
STATE.mode = mode;
STATE.buildPreview = null;
STATE.selectedTowerIdx = -1;
document.getElementById('btn-mode-edit').classList.toggle('active', mode === 'edit');
document.getElementById('btn-mode-build').classList.toggle('active', mode === 'build');
const modeDisplay = document.getElementById('mode-display');
modeDisplay.textContent = mode === 'edit' ? '编辑模式' : '建造模式';
modeDisplay.style.color = mode === 'edit' ? 'var(--warn)' : 'var(--success)';
},
togglePause() {
if (STATE.gameState === 'battle') {
STATE.gameState = 'paused';
this.showToast('游戏已暂停');
} else if (STATE.gameState === 'paused') {
STATE.gameState = 'battle';
this.showToast('游戏已继续');
} else if (STATE.gameState === 'idle') {
this.showToast('请先开始一波战斗');
}
this.updateDisplay();
},
updateDisplay() {
document.getElementById('wave-num').textContent = STATE.wave;
document.getElementById('gold-display').textContent = STATE.gold;
document.getElementById('hp-display').textContent = STATE.hp;
document.getElementById('kills-display').textContent = STATE.kills;
document.getElementById('speed-display').textContent = STATE.speed;
// HP颜色
const hpEl = document.getElementById('hp-display');
if (STATE.hp <= 5) hpEl.style.color = '#f44336';
else if (STATE.hp <= 10) hpEl.style.color = '#ff9800';
else hpEl.style.color = 'var(--hp)';
},
updateSpeedButtons() {
document.querySelectorAll('.speed-btns button').forEach(btn => {
btn.classList.toggle('active', parseInt(btn.dataset.speed) === STATE.speed);
});
document.getElementById('speed-display').textContent = STATE.speed;
},
showToast(msg) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.classList.add('show');
if (this.toastTimer) clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => toast.classList.remove('show'), 1500);
},
openOverlay(id) {
document.getElementById('overlay-' + id).classList.remove('hidden');
},
closeOverlay(id) {
document.getElementById('overlay-' + id).classList.add('hidden');
},
showStats() {
const content = document.getElementById('stats-content');
let totalInvested = 0;
STATE.towers.forEach(t => {
const def = WEAPON_DEFS[t.type];
for (let i = 0; i <= t.level; i++) totalInvested += def.levels[i].cost;
});
const towerCounts = {};
STATE.towers.forEach(t => {
const key = WEAPON_DEFS[t.type].name;
towerCounts[key] = (towerCounts[key] || 0) + 1;
});
content.innerHTML = `
<p><b>当前波次:</b> ${STATE.wave}</p>
<p><b>击杀数:</b> ${STATE.kills}</p>
<p><b>剩余金币:</b> ${STATE.gold}</p>
<p><b>基地生命:</b> ${STATE.hp}/${CFG.INIT_HP}</p>
<p><b>路线数:</b> ${STATE.paths.length}</p>
<p><b>塔总数:</b> ${STATE.towers.length}</p>
<p><b>总投入金币:</b> ${totalInvested}</p>
${Object.entries(towerCounts).map(([k,v]) => `<p><b>${k}:</b> ${v}座</p>`).join('')}
`;
this.openOverlay('stats');
},
};
// ============ 启动 ============
Game.init();
</script>
</body>
</html>