d3856cb52a
add a comprehensive gitignore file to exclude unnecessary files like dependencies, runtime data, uploads, local configs and debug temporary files
2055 lines
66 KiB
HTML
2055 lines
66 KiB
HTML
<!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> 暂停/继续 <span class="key">1/2/3</span> 速度切换</p>
|
||
<p><span class="key">E</span> 编辑模式 <span class="key">B</span> 建造模式</p>
|
||
<p><span class="key">滚轮</span> 缩放 <span class="key">右键拖拽</span> 平移画布</p>
|
||
<p><b>兵种克制:</b></p>
|
||
<p>机枪→轻型步兵 火炮→重装步兵 反坦克→装甲车/坦克 防空→直升机</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> |