化学塔防
学校办的信息学科活动,大概意思就是用 AI 开发,制作网站游戏,体现数理化学科或者学校特色。
在五一以及后几个晚自习完成了。Workbuddy 还是很强的。
美术效果几乎没有,机制也没认真想。创意和其他几个作品相比差多了。总之看下来糖糖的。
一起制作的机房同学
游戏在学校网站上,here。但是教练说服务器只会租一个月,所以可能之后就看不到了?
代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>化学反应塔防</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body { width:100%; height:100%; overflow:hidden; }
body {
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: #111; display: flex; justify-content: center; align-items: center;
}
.game-box {
position: relative; width:100%; height:100%;
background: #FFF; overflow: hidden; display: flex; flex-direction: column;
}
.top-bar {
position: relative; z-index:10;
display: flex; align-items: center; gap: 16px;
padding: 8px 16px; border-bottom: 0.5px solid #DDD;
font-size: 13px; color: #555; background: #FFF;
}
.top-bar .title { font-weight: 600; color: #2C3E50; font-size: 15px; }
.top-bar .spacer { flex: 1; }
.canvas-wrap {
flex: 1; min-height: 0; overflow: hidden;
display: flex; justify-content: center; align-items: center;
background: #1a1a1a;
}
#gameCanvas { display: block; cursor: crosshair; vertical-align: middle; }
.bottom-bar {
position: relative; z-index:10;
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-top: 0.5px solid #DDD;
background: #FAFAFA; min-height: 56px; flex-wrap: wrap;
}
.tower-btn {
padding: 5px 10px; border: 1px solid #CCC; border-radius: 8px;
background: #FFF; cursor: pointer; font-size: 12px;
display: flex; gap: 4px; align-items: center; transition: all 0.1s;
}
.tower-btn:hover { background: #F0F0F0; }
.tower-btn.selected { background: #3498DB; color: #FFF; border-color: #2980B9; }
.tower-btn .cost { color: #E67E22; font-weight: 600; }
.tower-btn.selected .cost { color: #FFF; }
#speedBtn { margin-left: auto; padding: 4px 12px; cursor: pointer; }
/* ========= 介绍界面 ========= */
#introScreen {
position: fixed; inset: 0;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
display: flex; flex-direction: column;
align-items: center; justify-content: center;
z-index: 999; font-family: inherit; color: #FFF;
overflow-y: auto; padding: 30px 20px;
}
#introScreen h1 { font-size: 28px; margin-bottom: 20px; letter-spacing: 2px; }
#introContent {
max-width: 620px; background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12); border-radius: 16px;
padding: 28px 32px; line-height: 1.9; font-size: 14px;
}
#introContent h2 { font-size: 16px; color: #F6AD55; margin: 16px 0 8px; }
#introContent h2:first-child { margin-top: 0; }
#introContent table { width: 100%; border-collapse: collapse; margin: 8px 0 12px; font-size: 13px; }
#introContent th { background: rgba(255,255,255,0.1); padding: 6px 10px; text-align: left; }
#introContent td { padding: 5px 10px; border-bottom: 1px solid rgba(255,255,255,0.06); }
#introContent ul { margin: 6px 0 10px 0; padding-left: 20px; }
#introContent li { margin-bottom: 4px; }
#introStartBtn {
margin-top: 24px; padding: 12px 40px;
background: #3498DB; color: #FFF;
border: none; border-radius: 30px;
font-size: 16px; font-weight: bold;
cursor: pointer; transition: background 0.2s, transform 0.1s;
letter-spacing: 2px;
}
#introStartBtn:hover { background: #2980B9; transform: translateY(-2px); }
/* ========= 选关界面 ========= */
#startScreen {
position: fixed; inset: 0;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
display: flex; flex-direction: column;
align-items: center; justify-content: center;
z-index: 999; font-family: inherit; color: #FFF;
overflow-y: auto; padding: 20px 0;
}
#startScreen h1 { font-size: 26px; margin-bottom: 16px; letter-spacing: 2px; }
#difficultyBar {
display: flex; gap: 12px; margin-bottom: 24px; align-items: center;
}
#difficultyBar span { font-size: 13px; opacity: 0.6; }
.diff-btn {
padding: 7px 22px; border-radius: 20px; border: 1.5px solid #555;
background: transparent; color: #AAA; cursor: pointer;
font-size: 13px; letter-spacing: 1px; transition: all 0.15s;
}
.diff-btn:hover { border-color: #F6AD55; color: #F6AD55; }
.diff-btn.active-easy { background: #2a7a4b; border-color: #2ecc71; color: #2ecc71; font-weight: 600; }
.diff-btn.active-normal{ background: #2c4880; border-color: #3498db; color: #74b9ff; font-weight: 600; }
#levelSelect { display: flex; gap: 24px; flex-wrap: wrap; justify-content: center; }
.lvl-btn {
width: 120px; height: 90px;
border-radius: 12px; border: 2px solid #555;
background: #2d3748; color: #FFF;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
cursor: pointer; font-size: 14px; transition: 0.2s;
gap: 4px;
}
.lvl-btn:hover:not(.locked) { border-color: #F6AD55; background: #4a5568; transform: translateY(-2px); }
.lvl-btn.locked { opacity: 0.4; cursor: not-allowed; }
#msgBox {
display: none; position: fixed; top: 50px; left: 50%; transform: translateX(-50%);
background: rgba(231,76,60,0.95); color: #FFF; padding: 6px 20px; border-radius: 6px; font-size: 13px; z-index: 999;
}
/* ========= 跳关浮层 ========= */
#skipOverlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.55); z-index: 1000;
align-items: center; justify-content: center;
}
#skipOverlay.open { display: flex; }
#skipPanel {
background: #1e2535; border-radius: 16px;
padding: 24px 28px; min-width: 320px;
box-shadow: 0 8px 40px rgba(0,0,0,0.6);
color: #FFF; font-family: inherit;
}
#skipPanel h3 { font-size: 16px; margin-bottom: 16px; color: #F6AD55; letter-spacing: 1px; }
#skipLevelList { display: flex; flex-direction: column; gap: 8px; }
.skip-lvl-btn {
padding: 10px 16px; border-radius: 8px;
border: 1px solid #3d4f6b; background: #2d3748;
color: #FFF; cursor: pointer; font-size: 13px;
display: flex; align-items: center; gap: 10px;
transition: background 0.15s, border-color 0.15s;
text-align: left;
}
.skip-lvl-btn:hover { background: #3a4a60; border-color: #F6AD55; }
.skip-lvl-btn .tag {
font-size: 11px; padding: 2px 7px; border-radius: 4px;
background: rgba(246,173,85,0.15); color: #F6AD55;
font-weight: 600; min-width: 34px; text-align: center;
}
.skip-lvl-btn.endless .tag { background: rgba(78,205,196,0.15); color: #4ECDC4; }
#skipCloseBtn {
margin-top: 16px; width: 100%; padding: 8px;
border-radius: 8px; border: 1px solid #555;
background: transparent; color: #AAA;
cursor: pointer; font-size: 13px; transition: background 0.15s;
}
#skipCloseBtn:hover { background: #2d3748; color: #FFF; }
</style>
</head>
<body>
<!-- 介绍界面 -->
<div id="introScreen">
<h1>🧪 化学反应塔防</h1>
<div id="introContent">
<h2>游戏简介</h2>
<p>一款将高中化学融入策略塔防的游戏。在这里,你不需要大炮和箭塔——你需要的是<strong>盐酸、高锰酸钾、硝酸钡</strong>,以及一颗懂化学的大脑。</p>
<p>游戏共设 5 个关卡,涵盖高中化学的核心知识模块,并附有挑战无上限的<strong>无尽双线模式</strong>:</p>
<table>
<tr><th>关卡</th><th>主题</th><th>涉及知识</th></tr>
<tr><td>1-1</td><td>酸碱中和</td><td>HCl、NaOH 与酸碱反应</td></tr>
<tr><td>1-2</td><td>沉淀与置换</td><td>Fe 的置换反应、BaSO₄ 沉淀</td></tr>
<tr><td>1-3</td><td>氧化还原</td><td>KMnO₄ 氧化 SO₂、活性炭吸附</td></tr>
<tr><td>2-1</td><td>烯烃加成与醇的氧化</td><td>Br₂ 加成、KMnO₄ 氧化醇类</td></tr>
<tr><td>2-2</td><td>羧酸与酯的水解</td><td>NaOH 中和羧酸、酯的碱性水解</td></tr>
<tr><td>无尽</td><td>双线综合挑战</td><td>全部化学塔混合运用</td></tr>
</table>
<h2>游戏玩法</h2>
<p>在敌人(有害化学物质)沿路径涌向终点之前,将对应的<strong>化学试剂塔</strong>放置在路旁,利用化学反应将它们消灭。每一次成功的反应都会在屏幕上弹出真实的<strong>化学方程式</strong>,让你在游戏中不知不觉地巩固知识。</p>
<ul>
<li>💰 <strong>击败敌人获得金币</strong>,用于购买和升级塔</li>
<li>⚗️ <strong>每种塔只能攻击特定化学物质</strong>,需根据敌人类型合理布局</li>
<li>🔄 <strong>部分敌人消灭后会变形</strong>(如酯水解为醇),需二次处理</li>
<li>❤️ <strong>敌人到达终点扣除生命值</strong>,生命归零即告失败</li>
</ul>
<p style="font-size:12px;color:rgba(255,255,255,0.45);margin-top:8px;">为了提高可玩性,使特定的塔攻击特定的敌人,一些反应在游戏中不进行。如高锰酸钾与烯烃反应,氢氧化钠与铜离子。</p>
</div>
<button id="introStartBtn" onclick="enterLevelSelect()">开始游戏 →</button>
</div>
<div id="startScreen">
<h1>🧪 化学反应塔防</h1>
<div id="difficultyBar">
<span>难度:</span>
<button class="diff-btn active-normal" id="diffEasyBtn" onclick="setDifficulty('easy')">✦ 简单</button>
<button class="diff-btn active-normal" id="diffNormalBtn" onclick="setDifficulty('normal')">● 普通</button>
</div>
<div id="levelSelect"></div>
</div>
<div class="game-box" id="gameBox" style="display:none">
<div class="top-bar">
<span class="title">🧪 化学反应塔防</span>
<span id="levelInfo">第1关:酸碱中和</span>
<span class="spacer"></span>
<span id="goldInfo">💰 ¥300</span>
<span id="livesInfo">❤️ 100</span>
<span id="waveInfo">波次: 0/8</span>
<button id="speedBtn" onclick="toggleSpeed()">▶ 正常</button>
<button id="skipJumpBtn" onclick="openSkipOverlay()" style="padding:4px 12px;cursor:pointer;background:#2d3748;color:#F6AD55;border:1px solid #4a5568;border-radius:4px;font-size:12px;">跳关 ☰</button>
</div>
<div class="canvas-wrap">
<canvas id="gameCanvas" width="600" height="420"></canvas>
</div>
<div class="bottom-bar" id="bottomBar"></div>
</div>
<div id="msgBox"></div>
<!-- 跳关浮层 -->
<div id="skipOverlay" onclick="if(event.target===this)closeSkipOverlay()">
<div id="skipPanel">
<h3>⚗️ 跳转关卡</h3>
<div id="skipLevelList"></div>
<button id="skipCloseBtn" onclick="closeSkipOverlay()">取消</button>
</div>
</div>
<script>
// ============================================================
// 化学反应塔防 — 完整可运行版
// 路径点全部对齐 40px 格子中心
// 格子中心坐标:x = 20 + c*40, y = 20 + r*40
// ============================================================
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// ========== 全屏缩放逻辑(高清 + 跨浏览器兼容) ==========
const LOGICAL_W = 600, LOGICAL_H = 420;
const W = LOGICAL_W, H = LOGICAL_H;
let dpr = Math.min(window.devicePixelRatio || 1, 2);
let physW = 0, physH = 0;
function resizeCanvas() {
const ww = window.innerWidth, wh = window.innerHeight;
dpr = Math.min(window.devicePixelRatio || 1, 2);
physW = ww * dpr;
physH = wh * dpr;
canvas.width = physW;
canvas.height = physH;
canvas.style.width = ww + 'px';
canvas.style.height = wh + 'px';
const sx = physW / LOGICAL_W, sy = physH / LOGICAL_H;
ctx.setTransform(sx, 0, 0, sy, 0, 0);
}
window.addEventListener('load', function() {
resizeCanvas();
window.addEventListener('resize', function() { resizeCanvas(); });
});
// 屏幕坐标 → 游戏逻辑坐标
function screenToGame(sx, sy) {
const rect = canvas.getBoundingClientRect();
return {
x: (sx - rect.left) / rect.width * LOGICAL_W,
y: (sy - rect.top) / rect.height * LOGICAL_H
};
}
// ========== 路径定义(全部对齐 40px 格子中心)==========
// 格子中心公式:cx = 20 + c*40, cy = 20 + r*40
// c = 列号 0..14, r = 行号 0..9
// 路宽逻辑:怪物沿路径点移动,道路格子由 buildGrid 动态计算
const PATH1 = [
// 从左侧屏幕外进入,沿 r=2 行向右,下到 r=7,再上到 r=1,从右侧离开
{x:-20, y:100}, // 左侧入口(r=2)
{x:100, y:100}, // (c=2, r=2)
{x:100, y:300}, // 下到 (c=2, r=7)
{x:340, y:300}, // 右到 (c=8, r=7)
{x:340, y:60}, // 上到 (c=8, r=1)
{x:580, y:60}, // 右到 (c=14, r=1)
{x:620, y:60} // 右侧出口
];
const PATH2A = [
// 第2关主线:从左侧进入,下到 r=5,右到 c=11,下到 r=8,离开
{x:-20, y:100}, // (c=-1, r=2)
{x:220, y:100}, // (c=5, r=2)
{x:220, y:220}, // 下到 (c=5, r=5)
{x:460, y:220}, // 右到 (c=11, r=5)
{x:460, y:340}, // 下到 (c=11, r=8)
{x:620, y:340} // 右侧出口
];
const PATH2B = [
// 第2关支线:从右侧进入,左到 c=11,下到汇合点
{x:620, y:60}, // 右侧入口(r=1)
{x:460, y:60}, // 左到 (c=11, r=1)
{x:460, y:220} // 下到汇合点 (c=11, r=5),与 PATH2A 共享
];
const PATH3 = [
// 第3关:从左侧进入,下到 r=8,右到 c=8,上到 r=1,右到 c=13,下到 r=8,离开
{x:-20, y:60}, // (c=-1, r=1)
{x:140, y:60}, // (c=3, r=1)
{x:140, y:340}, // 下到 (c=3, r=8)
{x:340, y:340}, // 右到 (c=8, r=8)
{x:340, y:60}, // 上到 (c=8, r=1)
{x:540, y:60}, // 右到 (c=13, r=1)
{x:540, y:340}, // 下到 (c=13, r=8)
{x:620, y:340} // 右侧出口
];
// ========= 有机反应路径 =========
const PATH4 = [ // 2-1 关 S 型路径(烯烃加成与醇的氧化)
{x:-20,y:220},{x:100,y:220},{x:100,y:140},{x:220,y:140},
{x:220,y:300},{x:340,y:300},{x:340,y:180},{x:460,y:180},
{x:460,y:260},{x:580,y:260}
];
const PATH5 = [ // 2-2 关 S 型路径(羧酸与酯的水解)
{x:20,y:100},{x:140,y:100},{x:140,y:260},{x:260,y:260},
{x:260,y:140},{x:380,y:140},{x:380,y:300},{x:500,y:300},
{x:500,y:180},{x:580,y:180}
];
const PATH6A = [ // 无尽模式上线 (r=2~4区域)
{x:-20,y:100},{x:20,y:100},{x:140,y:100},{x:140,y:180},{x:340,y:180},
{x:340,y:100},{x:460,y:100},{x:460,y:180},{x:580,y:180},{x:620,y:180}
];
const PATH6B = [ // 无尽模式下线 (r=5~7区域)
{x:-20,y:300},{x:20,y:300},{x:340,y:300},{x:340,y:220},{x:460,y:220},
{x:460,y:300},{x:580,y:300},{x:620,y:300}
];
function getPathByKey(level, key) {
if (level === 1) return PATH1;
if (level === 2) return key === '2B' ? PATH2B : PATH2A;
if (level === 3) return PATH3;
if (level === 4) return PATH4;
if (level === 5) return PATH5;
if (level === 6) return key === '6B' ? PATH6B : PATH6A;
}
// 验证路径点是否对齐格子中心(调试用)
function debugCheckPath(name, path) {
for (const p of path) {
const cx = Math.round((p.x - 20) / 40);
const cy = Math.round((p.y - 20) / 40);
const expectedX = 20 + cx * 40;
const expectedY = 20 + cy * 40;
if (Math.abs(p.x - expectedX) > 1 || Math.abs(p.y - expectedY) > 1) {
console.warn('路径未对齐格子中心: ' + name + ' 点(' + p.x + ',' + p.y + ') 最近格子中心(' + expectedX + ',' + expectedY + ')');
}
}
}
debugCheckPath('PATH1', PATH1);
debugCheckPath('PATH2A', PATH2A);
debugCheckPath('PATH2B', PATH2B);
debugCheckPath('PATH3', PATH3);
// ========== 方格系统 ==========
const CELL = 40;
const COLS = 15; // 0..14
const ROWS = 10; // 0..9
const _grid = []; // _grid[r][c] = true(路)/false(空地)
let _roadCells = []; // 有序道路格子中心 [{x, y}],按路径遍历顺序
// 通过格子中心坐标反算行列
function cellOf(px, py) {
return {
c: Math.round((px - 20) / CELL),
r: Math.round((py - 20) / CELL)
};
}
// 有序遍历一条路径经过的格子,生成按路径顺序的格子中心列表
// 自动裁剪超出网格范围的格子
function tracePathCells(path) {
const cells = []; // 按路径顺序的 {c, r}
for (let i = 0; i < path.length - 1; i++) {
const a = path[i], b = path[i + 1];
const aCell = cellOf(a.x, a.y);
const bCell = cellOf(b.x, b.y);
if (a.y === b.y) {
// 水平段:固定行 r,按方向遍历列
const r = aCell.r;
const step = aCell.c <= bCell.c ? 1 : -1;
const startC = aCell.c;
const endC = bCell.c;
let c = startC;
while (step > 0 ? c <= endC : c >= endC) {
// 只保留网格内的格子
if (c >= 0 && c < COLS && r >= 0 && r < ROWS) {
if (cells.length === 0 || cells[cells.length-1].r !== r || cells[cells.length-1].c !== c) {
cells.push({r, c});
}
}
c += step;
}
} else {
// 竖直段:固定列 c,按方向遍历行
const c = aCell.c;
const step = aCell.r <= bCell.r ? 1 : -1;
const startR = aCell.r;
const endR = bCell.r;
let r = startR;
while (step > 0 ? r <= endR : r >= endR) {
// 只保留网格内的格子
if (c >= 0 && c < COLS && r >= 0 && r < ROWS) {
if (cells.length === 0 || cells[cells.length-1].r !== r || cells[cells.length-1].c !== c) {
cells.push({r, c});
}
}
r += step;
}
}
}
return cells;
}
function buildGrid(level) {
// 初始化 grid
for (let r = 0; r < ROWS; r++) {
_grid[r] = [];
for (let c = 0; c < COLS; c++) _grid[r][c] = false;
}
// 收集所有路径的有序格子
const paths = level===1 ? [PATH1] : level===2 ? [PATH2A,PATH2B] : level===3 ? [PATH3] : level===4 ? [PATH4] : level===5 ? [PATH5] : [PATH6A,PATH6B];
const seen = new Set();
_roadCells = [];
for (const p of paths) {
const cells = tracePathCells(p);
for (const cell of cells) {
const key = cell.r * COLS + cell.c;
if (!seen.has(key)) {
seen.add(key);
_roadCells.push({ x: 20 + cell.c * CELL, y: 20 + cell.r * CELL });
}
if (cell.r >= 0 && cell.r < ROWS && cell.c >= 0 && cell.c < COLS) {
_grid[cell.r][cell.c] = true;
}
}
}
}
// 绘制格子底板(先绘道路格子深色效果,再绘空地)
function drawGridBackground() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const x = c * CELL, y = r * CELL;
if (_grid[r][c]) {
// 道路格子:深灰底色 + 细微纹理
ctx.fillStyle = '#4A5568';
ctx.fillRect(x + 1, y + 1, CELL - 2, CELL - 2);
// 格子内纹理线(模拟道路质感)
ctx.strokeStyle = '#5D6D7E';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(x + CELL - 2, y + 4);
ctx.lineTo(x + CELL - 2, y + CELL - 4);
ctx.moveTo(x + 4, y + CELL - 2);
ctx.lineTo(x + CELL - 4, y + CELL - 2);
ctx.stroke();
} else {
// 空地格子:暖白色
ctx.fillStyle = '#F7FAFC';
ctx.fillRect(x, y, CELL, CELL);
}
}
}
// 绘制细微的格子分隔线(所有格子)
ctx.strokeStyle = '#E2E8F0';
ctx.lineWidth = 0.5;
for (let r = 0; r <= ROWS; r++) {
ctx.beginPath();
ctx.moveTo(0, r * CELL);
ctx.lineTo(COLS * CELL, r * CELL);
ctx.stroke();
}
for (let c = 0; c <= COLS; c++) {
ctx.beginPath();
ctx.moveTo(c * CELL, 0);
ctx.lineTo(c * CELL, ROWS * CELL);
ctx.stroke();
}
}
// 按路径分别绘制虚线,避免多路径时连成一条折线
function drawPath(level) {
const paths = level===1 ? [PATH1] : level===2 ? [PATH2A,PATH2B] : level===3 ? [PATH3] : level===4 ? [PATH4] : level===5 ? [PATH5] : [PATH6A,PATH6B];
ctx.save();
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2;
ctx.setLineDash([8, 8]);
ctx.lineCap = 'round';
for (const path of paths) {
const cells = tracePathCells(path);
const pts = cells
.filter(cell => cell.c >= 0 && cell.c < COLS && cell.r >= 0 && cell.r < ROWS)
.map(cell => ({ x: 20 + cell.c * CELL, y: 20 + cell.r * CELL }));
if (pts.length < 2) continue;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) {
ctx.lineTo(pts[i].x, pts[i].y);
}
ctx.stroke();
}
ctx.restore();
}
// 鼠标所在可用格子
function getHoverCell(x, y) {
const c = Math.floor(x / CELL);
const r = Math.floor(y / CELL);
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return null;
if (_grid[r][c]) return null;
return { cx: 20 + c*CELL, cy: 20 + r*CELL, r, c };
}
// 格子是否被塔占用
function cellOccupied(cx, cy) {
for (const t of gs.towers) {
if (Math.hypot(t.x - cx, t.y - cy) < CELL * 0.8) return true;
}
return false;
}
// ========== 塔定义 ==========
const TDEF = {
hcl: {name:'盐酸塔', formula:'HCl', cost:100, damage:25, range:100, rate:1200, color:'#FF6B6B',
targets:['nh3','naoh_enemy'],
targetsEq:{
nh3:'NH₃ + HCl → NH₄Cl',
naoh_enemy:'HCl + NaOH → NaCl + H₂O'
}},
naoh: {name:'氢氧化钠塔',formula:'NaOH', cost:150, damage:30, range:110, rate:1000, color:'#4ECDC4',
targets:['h2so4','ch3cooh','c2h5cooh'],
targetsEq:{
h2so4:'H₂SO₄ + 2NaOH → Na₂SO₄ + 2H₂O',
ch3cooh:'CH₃COOH + NaOH → CH₃COONa + H₂O',
c2h5cooh:'C₂H₅COOH + NaOH → C₂H₅COONa + H₂O'
}},
fe: {name:'铁塔', formula:'Fe', cost:150, damage:40, range:90, rate:1400, color:'#95A5A6',
targets:['cu2','ag'], eq:'Fe + CuSO₄ → FeSO₄ + Cu'},
bano32:{name:'硝酸钡塔', formula:'Ba(NO₃)₂', cost:160, damage:0, range:100, rate:1500, color:'#E67E22',
targets:['so4','h2so4'], eq:'Ba²⁺ + SO₄²⁻ → BaSO₄↓', solidify:200},
kmno4: {name:'高锰酸钾塔',formula:'KMnO₄',cost:180, damage:30, range:115, rate:1000, color:'#8E44AD',
targets:['so2','c2h5oh','c3h7oh','ch3oh'],
targetsEq:{
so2:'2KMnO₄ + 5SO₂ + 2H₂O → K₂SO₄ + 2MnSO₄ + 2H₂SO₄',
c2h5oh:'C₂H₅OH + [O] → CH₃CHO + H₂O',
c3h7oh:'C₃H₇OH + [O] → C₃H₆O + H₂O',
ch3oh:'CH₃OH + [O] → HCHO + H₂O'
}},
carbon:{name:'活性炭塔', formula:'C', cost:90, damage:20, range:80, rate:1500, color:'#2C3E50',
targets:['co'], eq:'C(活性) 吸附有害气体'},
// ---- 有机反应塔(2-1、2-2 关)----
br2_add: {name:'溴水加成塔',formula:'Br₂', cost:150,damage:22,range:120,rate:1000,color:'#FF4500',
targets:['c2h4','c3h6'], eq:'C₂H₄ + Br₂ → C₂H₄Br₂'},
naoh_hydro: {name:'氢氧化钠水解塔',formula:'NaOH/H₂O',cost:220,damage:35,range:125,rate:850,color:'#B0E0E6',
targets:['ch3cooc2h5','c2h5cooch3'],
eq:'CH₃COOC₂H₅ + NaOH → CH₃COONa + C₂H₅OH'}
};
// ========== 敌人定义 ==========
const EDEF = {
nh3: {name:'氨气', formula:'NH₃', hp:80, speed:2.0, reward:50, dmg:1, color:'#2ECC71'},
h2so4: {name:'硫酸', formula:'H₂SO₄', hp:120, speed:1.2, reward:80, dmg:2, color:'#E74C3C'},
naoh_enemy: {name:'氢氧化钠',formula:'NaOH', hp:120, speed:1.2, reward:80, dmg:2, color:'#1ABC9C'},
so2: {name:'二氧化硫',formula:'SO₂', hp:100, speed:1.5, reward:60, dmg:1, color:'#F1C40F'},
cu2: {name:'铜离子', formula:'Cu²⁺', hp:150, speed:1.0, reward:100, dmg:2, color:'#E67E22'},
co: {name:'一氧化碳',formula:'CO', hp:100, speed:1.8, reward:70, dmg:1, color:'#34495E'},
ag: {name:'银离子', formula:'Ag⁺', hp:130, speed:1.0, reward:90, dmg:2, color:'#BDC3C7'},
so4: {name:'硫酸根', formula:'SO₄²⁻', hp:500, speed:0.8, reward:120, dmg:3, color:'#8E44AD'},
// ---- 有机反应敌人(2-1、2-2 关)----
c2h4: {name:'乙烯', formula:'C₂H₄', hp:60, speed:2.0, reward:40, dmg:2, color:'#ADD8E6'},
c3h6: {name:'丙烯', formula:'C₃H₆', hp:80, speed:1.8, reward:50, dmg:2, color:'#90EE90'},
c2h5oh: {name:'乙醇', formula:'C₂H₅OH', hp:100,speed:1.9, reward:45, dmg:2, color:'#FFFFE0'},
c3h7oh: {name:'丙醇', formula:'C₃H₇OH', hp:120,speed:1.7, reward:55, dmg:3, color:'#E6E6FA'},
ch3cooh: {name:'乙酸', formula:'CH₃COOH', hp:130,speed:1.7, reward:50, dmg:3, color:'#FFD700'},
c2h5cooh: {name:'丙酸', formula:'C₂H₅COOH', hp:160,speed:1.6, reward:55, dmg:3, color:'#FFA500'},
ch3cooc2h5:{name:'乙酸乙酯',formula:'CH₃COOC₂H₅',hp:180,speed:1.5, reward:60, dmg:3, color:'#FF69B4',onDeathTransform:'c2h5oh'},
c2h5cooch3:{name:'丙酸甲酯',formula:'C₂H₅COOCH₃',hp:200,speed:1.4, reward:65, dmg:4, color:'#FF1493',onDeathTransform:'ch3oh'},
ch3oh: {name:'甲醇', formula:'CH₃OH', hp:80, speed:2.1, reward:35, dmg:2, color:'#E0F7FF'}
};
// ========== 关卡波次 ==========
function getWaves(level) {
if (level === 1) return [
{e:[{t:'nh3',c:5,i:800}]},
{e:[{t:'nh3',c:8,i:700}]},
{e:[{t:'nh3',c:6,i:600}]},
{e:[{t:'nh3',c:4,i:800},{t:'h2so4',c:2,i:1200}]},
{e:[{t:'h2so4',c:4,i:1000}]},
{e:[{t:'nh3',c:8,i:600},{t:'h2so4',c:3,i:1000}]},
{e:[{t:'h2so4',c:6,i:900}]},
{e:[{t:'nh3',c:10,i:500},{t:'h2so4',c:5,i:800}]}
];
if (level === 2) return [
{e:[{t:'cu2',c:5,i:900}]},
{e:[{t:'cu2',c:4,i:800},{t:'cu2',c:4,i:800,p:'2B'}]},
{e:[{t:'cu2',c:8,i:600}]},
{e:[{t:'cu2',c:5,i:700},{t:'ag',c:3,i:1000,p:'2B'}]},
{e:[{t:'ag',c:6,i:800,p:'2B'},{t:'ag',c:6,i:800}]},
{e:[{t:'so4',c:3,i:1200}]},
{e:[{t:'so4',c:4,i:1000},{t:'so4',c:4,i:1000,p:'2B'}]},
{e:[{t:'cu2',c:6,i:600},{t:'ag',c:4,i:800,p:'2B'},{t:'so4',c:3,i:1200}]},
{e:[{t:'so4',c:6,i:900,p:'2B'},{t:'so4',c:6,i:900}]},
{e:[{t:'cu2',c:8,i:500},{t:'ag',c:5,i:700,p:'2B'},{t:'so4',c:5,i:1000}]}
];
if (level === 3) return [
{e:[{t:'so2',c:6,i:700}]},
{e:[{t:'so2',c:10,i:600}]},
{e:[{t:'so2',c:12,i:500}]},
{e:[{t:'so2',c:5,i:600},{t:'co',c:4,i:800}]},
{e:[{t:'co',c:8,i:500}]},
{e:[{t:'co',c:6,i:700},{t:'co',c:6,i:700}]},
{e:[{t:'so2',c:8,i:500},{t:'co',c:5,i:600}]},
{e:[{t:'so2',c:10,i:400},{t:'co',c:10,i:400}]},
{e:[{t:'co',c:8,i:500},{t:'so2',c:8,i:500}]},
{e:[{t:'co',c:12,i:350}]},
{e:[{t:'so2',c:10,i:400},{t:'co',c:8,i:500},{t:'co',c:10,i:400}]}
];
if (level === 4) return [
{e:[{t:'c2h4',c:5,i:2000}]},
{e:[{t:'c2h4',c:4,i:1800},{t:'c3h6',c:3,i:1800}]},
{e:[{t:'c3h6',c:3,i:1600},{t:'c2h5oh',c:3,i:1600}]},
{e:[{t:'c2h5oh',c:4,i:1500},{t:'c3h7oh',c:2,i:1500}]},
{e:[{t:'c2h4',c:3,i:1400},{t:'c3h6',c:3,i:1400},{t:'c2h5oh',c:3,i:1400}]},
{e:[{t:'c3h6',c:4,i:1300},{t:'c3h7oh',c:4,i:1300}]},
{e:[{t:'c2h4',c:5,i:1200},{t:'c3h7oh',c:3,i:1200}]},
{e:[{t:'c2h5oh',c:4,i:1100},{t:'c3h7oh',c:4,i:1100}]},
{e:[{t:'c2h4',c:5,i:1000},{t:'c3h6',c:5,i:1000},{t:'c2h5oh',c:5,i:1000},{t:'c3h7oh',c:5,i:1000}]},
{e:[{t:'c2h4',c:3,i:900},{t:'c3h6',c:3,i:900},{t:'c2h5oh',c:2,i:900},{t:'c3h7oh',c:2,i:900}]}
];
if (level === 5) return [
{e:[{t:'ch3cooh',c:5,i:2000}]},
{e:[{t:'ch3cooh',c:4,i:1800},{t:'c2h5cooh',c:3,i:1800}]},
{e:[{t:'c2h5cooh',c:3,i:1600},{t:'ch3cooc2h5',c:3,i:1600}]},
{e:[{t:'ch3cooc2h5',c:4,i:1500},{t:'c2h5cooch3',c:2,i:1500}]},
{e:[{t:'ch3cooh',c:3,i:1400},{t:'c2h5cooh',c:3,i:1400},{t:'ch3cooc2h5',c:2,i:1400},{t:'c2h5cooch3',c:2,i:1400}]},
{e:[{t:'c2h5cooh',c:4,i:1300},{t:'c2h5cooch3',c:4,i:1300}]},
{e:[{t:'ch3cooh',c:5,i:1200},{t:'ch3cooc2h5',c:5,i:1200}]},
{e:[{t:'ch3cooh',c:4,i:1100},{t:'c2h5cooh',c:4,i:1100},{t:'ch3cooc2h5',c:3,i:1100},{t:'c2h5cooch3',c:3,i:1100}]},
{e:[{t:'ch3cooh',c:4,i:1000},{t:'c2h5cooh',c:4,i:1000},{t:'ch3cooc2h5',c:4,i:1000},{t:'c2h5cooch3',c:4,i:1000}]},
{e:[{t:'ch3cooh',c:3,i:900},{t:'c2h5cooh',c:3,i:900},{t:'ch3cooc2h5',c:3,i:900},{t:'c2h5cooch3',c:3,i:900}]}
];
return [];
}
function getAvailableTowers(level) {
if (level === 1) return ['hcl','naoh'];
if (level === 2) return ['fe','bano32'];
if (level === 3) return ['kmno4','carbon'];
if (level === 4) return ['br2_add','kmno4'];
if (level === 5) return ['naoh','naoh_hydro','kmno4'];
if (level === 6) return ['hcl','naoh','fe','bano32','kmno4','carbon','br2_add','naoh_hydro'];
return [];
}
function getLevelName(level) {
return ['','1-1:酸碱中和','1-2:沉淀与置换','1-3:氧化还原',
'2-1:烯烃加成与醇的氧化','2-2:羧酸与酯的水解'][level] || '';
}
// ========== 难度系统 ==========
let currentDifficulty = 'normal'; // 'easy' | 'normal'
const DIFFICULTY_SCALE = {
easy: {
towerCost: 0.7, // 塔费用 × 0.7
towerDmg: 1.4, // 塔伤害 × 1.4(含 solidify)
towerRange: 1.15, // 射程 × 1.15
towerRate: 0.8, // CD × 0.8(攻速更快)
enemyHp: 0.6, // 敌人血量 × 0.6
enemySpeed: 0.75, // 敌人移速 × 0.75
enemyDmg: 0.5, // 到终点扣血 × 0.5(最低 1)
initGoldAdd: 150, // 初始金币额外加
initLives: 30 // 初始生命
},
normal: {
towerCost: 1,
towerDmg: 1,
towerRange: 1,
towerRate: 1,
enemyHp: 1,
enemySpeed: 1,
enemyDmg: 1,
initGoldAdd: 0,
initLives: 20
}
};
function getTowerCost(type) {
return Math.round(TDEF[type].cost * DIFFICULTY_SCALE[currentDifficulty].towerCost);
}
function setDifficulty(d) {
currentDifficulty = d;
document.getElementById('diffEasyBtn').className = 'diff-btn' + (d === 'easy' ? ' active-easy' : '');
document.getElementById('diffNormalBtn').className = 'diff-btn' + (d === 'normal' ? ' active-normal' : '');
}
// ========== 游戏状态 ==========
const gs = {
level:1, gold:300, lives:100,
towers:[], enemies:[], waveIndex:0, totalWaves:0, waves:[],
spawnQueue:[], spawning:false, waveTimer:0,
gameOver:false, victory:false, paused:false, speed:1,
reactionsThisLevel:[], waitingForFirstWave:true
};
let selectedTowerType = null;
let selectedPlacedTower = null;
let mouseX = 0, mouseY = 0;
let msgTimer = null;
// ========== 工具函数 ==========
function dist(x1,y1,x2,y2) { return Math.sqrt((x1-x2)**2 + (y1-y2)**2); }
function isOnPath(x, y, level) {
const paths = level===1 ? [PATH1] : level===2 ? [PATH2A,PATH2B] : level===3 ? [PATH3] : level===4 ? [PATH4] : level===5 ? [PATH5] : [PATH6A,PATH6B];
for (const p of paths) {
for (const pt of p) { if (dist(x,y,pt.x,pt.y) < 25) return true; }
}
return false;
}
// ========== 敌人逻辑 ==========
function spawnEnemy(type, level, pathKey) {
const def = EDEF[type]; if (!def) return null;
const sc = DIFFICULTY_SCALE[currentDifficulty];
const hp = Math.round(def.hp * sc.enemyHp);
const path = getPathByKey(level, pathKey);
return {
type, name:def.name, formula:def.formula,
hp, maxHp:hp,
speed: def.speed * sc.enemySpeed,
reward: def.reward,
dmg: Math.max(1, Math.round(def.dmg * sc.enemyDmg)),
color: def.color,
path, pathIndex:0, x:path[0].x, y:path[0].y,
alive:true, reachingEnd:false, hitFlash:0, floatingText:null
};
}
function updateOneEnemy(e) {
if (!e.alive) return 'dead';
if (e.reachingEnd) return 'reached';
const target = e.path[e.pathIndex];
if (!target) { e.alive = false; e.reachingEnd = true; return 'reached'; }
// PATH2B 支线敌人到达汇合点 (460, 220) 后,继续沿 PATH2A 方向前进
if (e.path === PATH2B && e.pathIndex === e.path.length - 1
&& Math.abs(e.x - 460) < 4 && Math.abs(e.y - 220) < 4) {
// 创建新数组,不修改 PATH2B 原数组
e.path = [...PATH2B, {x:460, y:340}, {x:620, y:340}];
}
const dx = target.x - e.x, dy = target.y - e.y;
const d = Math.sqrt(dx*dx + dy*dy);
if (d < 2) { e.pathIndex++; }
else { e.x += (dx/d) * e.speed; e.y += (dy/d) * e.speed; }
if (e.hitFlash > 0) e.hitFlash--;
if (e.floatingText) {
e.floatingText.alpha -= 0.02;
e.floatingText.yOffset -= 0.5;
if (e.floatingText.alpha <= 0) e.floatingText = null;
}
return 'ok';
}
// ========== 塔逻辑 ==========
function createTower(type, x, y) {
const def = TDEF[type]; if (!def) return null;
const sc = DIFFICULTY_SCALE[currentDifficulty];
return {
type, x, y, level:0,
damage: Math.round(def.damage * sc.towerDmg),
range: Math.round(def.range * sc.towerRange),
rate: Math.round(def.rate * sc.towerRate),
lastFire:0, color: def.color,
firing: false, fireAnimTime: 0, solidify: !!def.solidify,
_solidifyVal: def.solidify ? Math.round(def.solidify * sc.towerDmg) : 0,
formula: def.formula, name: def.name, eq: def.eq
};
}
function towerCanAttack(tower, enemyType) {
const def = TDEF[tower.type];
return def && def.targets.includes(enemyType);
}
function towerDoAttack(tower, enemy) {
const now = Date.now();
if (now - tower.lastFire < tower.rate) return null;
if (dist(tower.x, tower.y, enemy.x, enemy.y) > tower.range) return null;
if (!towerCanAttack(tower, enemy.type)) return null;
const def = TDEF[tower.type];
tower.lastFire = now;
tower.firing = true;
tower.fireAnimTime = 15;
// 获取反应式:优先使用targetsEq映射,否则使用默认eq
const eq = (def.targetsEq && def.targetsEq[enemy.type]) ? def.targetsEq[enemy.type] : def.eq;
if (def.solidify) {
const dmg = tower._solidifyVal * (tower.level + 1);
enemy.hp -= dmg;
enemy.hitFlash = 8;
enemy.floatingText = {text: eq + ' -' + dmg, alpha:1.0, yOffset:0};
} else {
enemy.hp -= tower.damage;
enemy.hitFlash = 8;
enemy.floatingText = {text: eq, alpha:1.0, yOffset:0};
}
if (enemy.hp <= 0) enemy.alive = false;
return eq;
}
// ========== 高端塔绘制函数 ==========
function drawTowerShape(t) {
const r = 16 + t.level * 4;
ctx.save();
// 1) 选中时的范围环(保留原逻辑)
if (selectedPlacedTower === t) {
ctx.beginPath(); ctx.arc(t.x, t.y, t.range, 0, Math.PI*2);
ctx.strokeStyle = t.color + '60'; ctx.lineWidth = 1; ctx.stroke();
ctx.fillStyle = t.color + '15'; ctx.fill();
}
// 2) 底座:六边形
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const a = Math.PI/6 + Math.PI*2*i/6;
const method = i === 0 ? 'moveTo' : 'lineTo';
ctx[method](t.x + r*Math.cos(a), t.y + r*Math.sin(a));
}
ctx.closePath();
ctx.fillStyle = t.color; ctx.fill();
ctx.strokeStyle = '#333'; ctx.lineWidth = 1.5; ctx.stroke();
// 3) 类型特征标识(化学式文字)
ctx.fillStyle = '#FFF'; ctx.font = (r<18 ? 'bold 8px' : 'bold 9px') + ' sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(t.formula, t.x, t.y);
// 4) 等级指示:弧形能量条(0级=120°,1级=240°,2级=360°)
if (t.level >= 0) {
const totalDeg = (t.level + 1) * 120; // 0级→120°, 1级→240°, 2级→360°
const totalAngle = totalDeg * Math.PI / 180;
// 弧线居中显示(12点钟方向为对称轴)
const startAngle = -Math.PI / 2 - totalAngle / 2;
const endAngle = startAngle + totalAngle;
ctx.beginPath();
ctx.arc(t.x, t.y, r + 5, startAngle, endAngle);
ctx.strokeStyle = '#F39C12';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.stroke();
// 端点高光
if (t.level < 2) {
// 0级和1级:在弧线末端画一个小圆点
const tipX = t.x + (r + 5) * Math.cos(endAngle);
const tipY = t.y + (r + 5) * Math.sin(endAngle);
ctx.beginPath();
ctx.arc(tipX, tipY, 2.5, 0, Math.PI * 2);
ctx.fillStyle = '#F39C12';
ctx.fill();
} else {
// 2级(满级):外圈高光环
ctx.beginPath();
ctx.arc(t.x, t.y, r + 7, 0, Math.PI * 2);
ctx.strokeStyle = '#F39C1240';
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
ctx.restore();
}
function drawTower(t) {
drawTowerShape(t);
// 攻击动画:从塔中心到敌人的激光线段
if (t.firing) {
const p = 1 - t.fireAnimTime/15;
const r = 16 + t.level * 4; // 局部变量,供下方使用
// 找到最近的目标敌人
let best = null, bestD = Infinity;
for (const e of gs.enemies) {
if (!e.alive) continue;
if (!towerCanAttack(t, e.type)) continue;
const d = dist(t.x, t.y, e.x, e.y);
if (d < t.range && d < bestD) { bestD = d; best = e; }
}
ctx.save();
if (best) {
// 有目标:画激光
ctx.strokeStyle = t.color + Math.floor((1-p)*180).toString(16).padStart(2,'0');
ctx.lineWidth = 2 + (1-p)*2;
ctx.beginPath(); ctx.moveTo(t.x, t.y); ctx.lineTo(best.x, best.y); ctx.stroke();
// 命中闪光
ctx.beginPath(); ctx.arc(best.x, best.y, 4 + (1-p)*8, 0, Math.PI*2);
ctx.fillStyle = t.color + '80'; ctx.fill();
} else {
// 没有目标时的扩散环
ctx.beginPath(); ctx.arc(t.x, t.y, r + p*(t.range), 0, Math.PI*2);
ctx.strokeStyle = t.color + Math.floor((1-p)*80).toString(16).padStart(2,'0');
ctx.lineWidth = 2; ctx.stroke();
}
ctx.restore();
}
}
// ========== 高端敌人绘制函数 ==========
function drawEnemyShape(e) {
const s = 14;
ctx.save();
ctx.translate(e.x, e.y);
// 受击闪烁:黄色缩放
if (e.hitFlash > 0) {
const shake = 1 - e.hitFlash / 8;
ctx.scale(1 + shake * 0.25, 1 + shake * 0.25);
ctx.fillStyle = '#FFD700';
} else {
ctx.fillStyle = e.color;
}
// 统一三角形
ctx.beginPath();
ctx.moveTo(0, -s);
ctx.lineTo(s, s * 0.7);
ctx.lineTo(-s, s * 0.7);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.lineWidth = 1.2;
ctx.stroke();
// 中心高光
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.beginPath();
ctx.arc(0, -s * 0.15, s * 0.35, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// 化学式居中于三角形
ctx.save();
ctx.fillStyle = '#FFF';
ctx.font = 'bold 9px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = 2;
ctx.fillText(e.formula, e.x, e.y + 2);
ctx.restore();
}
function drawEnemy(e) {
if (!e.alive) return;
drawEnemyShape(e);
// 高端血条:加外框 + 渐变颜色
const bw = 30, bh = 4, bx = e.x - bw/2, by = e.y - 12 - 10;
// 外框
ctx.fillStyle = '#00000040'; ctx.fillRect(bx-0.5, by-0.5, bw+1, bh+1);
ctx.fillStyle = '#DDD'; ctx.fillRect(bx, by, bw, bh);
// 颜色渐变(分段绘制)
const hpRatio = e.hp / e.maxHp;
let barColor;
if (hpRatio > 0.5) barColor = '#2ECC71';
else if (hpRatio > 0.25) barColor = '#F39C12';
else barColor = '#E74C3C';
// 血量光效
ctx.fillStyle = barColor; ctx.fillRect(bx, by, bw * hpRatio, bh);
// 血条内高光
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(bx, by, bw * hpRatio, bh * 0.5);
// 浮动文字(反应式)
if (e.floatingText) {
ctx.save(); ctx.globalAlpha = e.floatingText.alpha;
ctx.fillStyle = '#E74C3C'; ctx.font = 'bold 11px sans-serif'; ctx.textAlign = 'center';
ctx.fillText(e.floatingText.text, e.x, e.y + 12 + 28 + e.floatingText.yOffset);
ctx.restore();
}
}
// ========== 波次管理 ==========
function loadWave(idx) {
if (idx >= gs.waves.length) return;
const wave = gs.waves[idx];
gs.spawnQueue = [];
for (const g of wave.e) {
for (let i=0; i<g.c; i++) {
gs.spawnQueue.push({type: g.t, pathKey: g.p || '1', delay: i * g.i, spawned: false});
}
}
gs.spawnQueue.sort((a,b) => a.delay - b.delay);
gs.spawning = true;
gs.waveTimer = 0;
gs.waitingForFirstWave = false;
}
// ========== 无尽模式波次生成 ==========
function getEndlessWave(n) {
// n: 波次序号(从0开始)
// 敌人类型池按波次解锁
const pool1 = ['nh3','h2so4']; // n<3
const pool2 = [...pool1,'cu2','ag']; // n<6
const pool3 = [...pool2,'so2','co']; // n<10
const pool4 = [...pool3,'c2h4','c3h6','c2h5oh','c3h7oh']; // n<15
const allPool = [...pool4,'so4','ch3cooh','c2h5cooh','ch3cooc2h5','c2h5cooch3','ch3oh'];
let pool;
if (n < 3) pool = pool1;
else if (n < 6) pool = pool2;
else if (n < 10) pool = pool3;
else if (n < 15) pool = pool4;
else pool = allPool;
// 每波敌人数递增
const countA = 6 + Math.floor(n / 3) * 2;
const countB = 4 + Math.floor(n / 3) * 2;
// 每10波加入so4
const boss = (n > 0 && n % 10 === 0) ? [{t:'so4',c:1,i:0}] : [];
// 随机构建本波敌人列表
const pickType = () => pool[Math.floor(Math.random() * pool.length)];
const enemiesA = [];
const enemiesB = [];
for (let i = 0; i < countA; i++) enemiesA.push({t:pickType(), p:'6A'});
for (let i = 0; i < countB; i++) enemiesB.push({t:pickType(), p:'6B'});
// 合并,加入boss
const list = [...enemiesA.map(e=>({t:e.t,c:1,i:Math.floor(Math.random()*1500),p:e.p})),
...enemiesB.map(e=>({t:e.t,c:1,i:Math.floor(Math.random()*1500),p:e.p})),
...boss.map(e=>({...e,c:1,i:500,p:Math.random()>0.5?'6A':'6B'}))];
// 合并同类同路径的敌人
const merged = [];
const keyMap = {};
for (const e of list) {
const key = e.t + '|' + (e.p||'6A');
if (keyMap[key]) { keyMap[key].c++; }
else { keyMap[key] = {t:e.t,c:1,i:e.i,p:e.p}; merged.push(keyMap[key]); }
}
return { e: merged };
}
function loadEndlessWave(idx) {
const wave = getEndlessWave(idx);
gs.spawnQueue = [];
for (const g of wave.e) {
for (let i = 0; i < g.c; i++) {
gs.spawnQueue.push({type:g.t, pathKey:g.p||'6A', delay:i*g.i, spawned:false});
}
}
gs.spawnQueue.sort((a,b)=>a.delay-b.delay);
gs.spawning = true;
gs.waveTimer = 0;
gs.waitingForFirstWave = false;
}
function initEndless() {
buildGrid(6);
gs.level = 6;
gs.isEndless = true;
gs.gold = 600 + DIFFICULTY_SCALE[currentDifficulty].initGoldAdd;
gs.lives = DIFFICULTY_SCALE[currentDifficulty].initLives === 20 ? 100 : 150;
gs.towers = [];
gs.enemies = [];
gs.gameOver = false;
gs.victory = false;
gs.waveIndex = 0;
gs.spawning = false;
gs.spawnQueue = [];
gs.waitingForFirstWave = true;
selectedTowerType = null;
selectedPlacedTower = null;
renderBottomBar();
updateInfoBar();
showMsg('无尽模式:双线挑战!');
setTimeout(()=>loadEndlessWave(0), 1500);
}
// ========== 主更新 ==========
function update(dt) {
if (gs.gameOver || gs.victory) return;
// 生成敌人
if (gs.spawning && gs.spawnQueue.length > 0) {
gs.waveTimer += dt * gs.speed * 1000;
let allDone = true;
for (const item of gs.spawnQueue) {
if (!item.spawned && gs.waveTimer >= item.delay) {
const e = spawnEnemy(item.type, gs.level, item.pathKey);
if (e) gs.enemies.push(e);
item.spawned = true;
}
if (!item.spawned) allDone = false;
}
if (allDone) { gs.spawning = false; gs.spawnQueue = []; }
}
// 更新敌人
for (let i=gs.enemies.length-1; i>=0; i--) {
const e = gs.enemies[i];
const status = updateOneEnemy(e);
if (status === 'dead') {
const def = EDEF[e.type];
if (def.onDeathTransform) {
// 酯类HP归零时转化为对应醇
const newType = def.onDeathTransform;
const newDef = EDEF[newType];
e.type = newType;
e.name = newDef.name;
e.formula = newDef.formula;
e.hp = newDef.hp;
e.maxHp = newDef.hp;
e.speed = newDef.speed;
e.reward = newDef.reward;
e.dmg = newDef.dmg;
e.color = newDef.color;
e.alive = true;
e.hitFlash = 10;
e.floatingText = {text:'→ '+newDef.name, alpha:1.0, yOffset:0};
showMsg('🔄 '+newDef.name+' 生成!');
} else {
gs.gold += def.reward;
gs.enemies.splice(i, 1);
}
} else if (status === 'reached') {
gs.lives -= EDEF[e.type].dmg;
gs.enemies.splice(i, 1);
if (gs.lives <= 0) { gs.lives = 0; gs.gameOver = true; return; }
}
}
// 检查波次完成
if (!gs.spawning && gs.enemies.length === 0 && !gs.waitingForFirstWave) {
if (gs.isEndless) {
// 无尽模式:无限推进
gs.waveIndex++;
gs.waitingForFirstWave = true;
setTimeout(() => loadEndlessWave(gs.waveIndex), 1500);
} else {
gs.waveIndex++;
if (gs.waveIndex >= gs.totalWaves) {
// 所有波次已完成,触发胜利
gs.victory = true;
setTimeout(showSummary, 800);
} else {
// 加载下一波
gs.waitingForFirstWave = true;
setTimeout(() => loadWave(gs.waveIndex), 2000);
}
}
}
// 塔攻击
const now = Date.now();
for (const t of gs.towers) {
if (t.firing) { t.fireAnimTime--; if (t.fireAnimTime <= 0) t.firing = false; }
if (t.firing) continue;
for (const e of gs.enemies) {
if (!e.alive) continue;
const eq = towerDoAttack(t, e);
if (eq) { gs.reactionsThisLevel.push(eq); break; }
}
}
}
// ========== 主渲染 ==========
function draw() {
ctx.fillStyle = '#FFF'; ctx.fillRect(0, 0, W, H);
// 选关界面时 _roadCells 未初始化,跳过游戏画面绘制
if (_roadCells.length === 0) return;
drawGridBackground();
drawPath(gs.level);
for (const t of gs.towers) drawTower(t);
for (const e of gs.enemies) drawEnemy(e);
// 放置模式:显示格子边框
if (selectedTowerType && mouseY < 400) {
const def = TDEF[selectedTowerType];
const hover = getHoverCell(mouseX, mouseY);
ctx.save();
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (_grid[r][c]) continue;
const x = c * CELL, y = r * CELL;
const cellCX = 20 + c*CELL, cellCY = 20 + r*CELL;
const occupied = cellOccupied(cellCX, cellCY);
if (cellCX === hover?.cx && cellCY === hover?.cy) {
ctx.fillStyle = '#3498DB40';
ctx.fillRect(x, y, CELL, CELL);
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2; ctx.setLineDash([]);
ctx.strokeRect(x + 1, y + 1, CELL - 2, CELL - 2);
ctx.beginPath(); ctx.arc(cellCX, cellCY, def.range, 0, Math.PI*2);
ctx.strokeStyle = def.color + '60'; ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]); ctx.stroke();
ctx.beginPath(); ctx.arc(cellCX, cellCY, 14, 0, Math.PI*2);
ctx.fillStyle = def.color + '70'; ctx.fill();
ctx.fillStyle = '#FFF'; ctx.font = 'bold 8px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(def.formula, cellCX, cellCY);
} else if (occupied) {
ctx.strokeStyle = '#CCC'; ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.strokeRect(x + 1, y + 1, CELL - 2, CELL - 2);
} else {
ctx.strokeStyle = '#B0D4F1'; ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.strokeRect(x + 1, y + 1, CELL - 2, CELL - 2);
}
}
}
ctx.restore();
}
// 选中塔的操作按钮
if (selectedPlacedTower && selectedPlacedTower.y < 400) {
const t = selectedPlacedTower;
if (t.level < 2) {
const upCost = getTowerCost(t.type) + (t.level === 0 ? 80 : 160);
if (gs.gold >= upCost) {
ctx.fillStyle = '#27AE60'; ctx.fillRect(t.x-40, t.y-50, 80, 18);
ctx.fillStyle = '#FFF'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('升级 ¥' + upCost, t.x, t.y-37);
t._btnUp = {x:t.x-40, y:t.y-50, w:80, h:18};
}
}
const sellVal = Math.floor(getTowerCost(t.type) * 0.6);
ctx.fillStyle = '#E74C3C'; ctx.fillRect(t.x+45, t.y-50, 60, 18);
ctx.fillStyle = '#FFF'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('出售 ¥' + sellVal, t.x+75, t.y-37);
t._btnSell = {x:t.x+45, y:t.y-50, w:60, h:18};
}
// 游戏结束 / 胜利
if (gs.gameOver) {
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#E74C3C'; ctx.font = 'bold 32px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('游戏结束', W/2, H/2);
ctx.fillStyle = '#FFF'; ctx.font = '14px sans-serif';
ctx.fillText('点击任意位置重新开始', W/2, H/2+30);
}
if (gs.victory && !gs.gameOver) {
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#27AE60'; ctx.font = 'bold 32px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('关卡完成!', W/2, H/2);
}
}
// ========== UI 更新 ==========
function updateInfoBar() {
document.getElementById('levelInfo').textContent = getLevelName(gs.level);
document.getElementById('goldInfo').textContent = '💰 ¥' + gs.gold;
document.getElementById('livesInfo').textContent = '❤️ ' + gs.lives;
document.getElementById('waveInfo').textContent = '波次: ' + Math.min(gs.waveIndex, gs.totalWaves) + '/' + gs.totalWaves;
// 实时同步底部防御塔按钮的可购买状态
const bar = document.getElementById('bottomBar');
if (bar) {
const btns = bar.querySelectorAll('.tower-btn');
const available = getAvailableTowers(gs.level);
btns.forEach((btn, i) => {
const tt = available[i];
if (!tt) return;
const cost = getTowerCost(tt);
btn.style.opacity = gs.gold >= cost ? '1' : '0.4';
});
}
}
function showMsg(text) {
const box = document.getElementById('msgBox');
box.textContent = text; box.style.display = 'block';
clearTimeout(msgTimer); msgTimer = setTimeout(() => { box.style.display = 'none'; }, 2000);
}
function renderBottomBar() {
const bar = document.getElementById('bottomBar');
const available = getAvailableTowers(gs.level);
let html = '';
for (const tt of available) {
const def = TDEF[tt];
const cost = getTowerCost(tt);
const sel = selectedTowerType === tt ? 'selected' : '';
const afford = gs.gold >= cost;
html += '<button class="tower-btn ' + sel + '" style="opacity:' + (afford?1:0.4) + ';border-color:' + def.color + '" onclick="selectTower(\'' + tt + '\')">'
+ '<span style="color:' + def.color + ';font-weight:bold;">' + def.formula + '</span>'
+ '<span class="cost"> ¥' + cost + '</span></button>';
}
bar.innerHTML = html;
}
window.selectTower = function(type) {
if (gs.gold < getTowerCost(type)) { showMsg('金币不足!'); return; }
selectedTowerType = (selectedTowerType === type) ? null : type;
selectedPlacedTower = null;
renderBottomBar();
};
function showSummary() {
const eqs = [...new Set(gs.reactionsThisLevel)];
let msg;
if (gs.isEndless) {
msg = '🎉 无尽模式结束!\n\n到达第 ' + (gs.waveIndex) + ' 波\n触发的反应:\n';
} else {
msg = '🎉 第' + gs.level + '关完成!\n\n触发的反应:\n';
}
if (eqs.length === 0) msg += '(无)';
else msg += eqs.map(e => '• ' + e).join('\n');
if (!gs.isEndless) unlockLevel(gs.level);
alert(msg);
document.getElementById('startScreen').style.display = 'flex';
document.getElementById('gameBox').style.display = 'none';
renderLevelButtons();
}
// ========== 选关界面逻辑 ==========
function getUnlocked() {
return Math.max(1, parseInt(localStorage.getItem('chemtd_unlocked')) || 1);
}
function unlockLevel(n) {
const cur = getUnlocked();
if (n + 1 > cur) localStorage.setItem('chemtd_unlocked', n + 1);
}
function startLevel(n) {
document.getElementById('startScreen').style.display = 'none';
document.getElementById('gameBox').style.display = 'flex';
initLevel(n);
}
function startEndless() {
document.getElementById('startScreen').style.display = 'none';
document.getElementById('gameBox').style.display = 'flex';
initEndless();
}
function renderLevelButtons() {
const sel = document.getElementById('levelSelect');
sel.innerHTML = '';
const unlocked = getUnlocked();
const levels = [
{ key:1, label:'1-1\u3000酸碱中和' },
{ key:2, label:'1-2\u3000沉淀与置换' },
{ key:3, label:'1-3\u3000氧化还原' },
{ key:4, label:'2-1\u3000烯烃加成与醇的氧化' },
{ key:5, label:'2-2\u3000羧酸与酯的水解' },
{ key:6, label:'🌟\u3000双线无尽挑战' }
];
for (const lv of levels) {
const btn = document.createElement('div');
btn.className = 'lvl-btn';
const parts = lv.label.split(' ');
btn.innerHTML = '<div style="font-size:11px;opacity:0.7;">' + parts[0] + '</div><div>' + parts[1] + '</div>';
if (lv.key <= 5 && lv.key <= unlocked) {
btn.onclick = function() { startLevel(lv.key); };
} else if (lv.key === 6) {
btn.onclick = function() { startEndless(); };
} else {
btn.classList.add('locked');
}
sel.appendChild(btn);
}
}
// ========== 初始化关卡 ==========
function initLevel(level) {
gs.isEndless = false;
buildGrid(level);
gs.level = level;
gs.gold = 300 + (level-1) * 100 + DIFFICULTY_SCALE[currentDifficulty].initGoldAdd;
gs.lives = DIFFICULTY_SCALE[currentDifficulty].initLives;
gs.towers = []; gs.enemies = [];
gs.reactionsThisLevel = [];
gs.gameOver = false; gs.victory = false;
gs.waveIndex = 0; gs.spawning = false; gs.spawnQueue = [];
gs.waves = getWaves(level);
gs.totalWaves = gs.waves.length;
gs.waitingForFirstWave = true;
selectedTowerType = null; selectedPlacedTower = null;
renderBottomBar();
updateInfoBar();
showMsg('第' + level + '关:' + getLevelName(level));
setTimeout(() => loadWave(0), 1500);
}
// ========== 鼠标 / 点击 ==========
canvas.addEventListener('click', function(e) {
const { x, y } = screenToGame(e.clientX, e.clientY);
if (gs.gameOver) { location.reload(); return; }
// 升级/出售按钮
if (selectedPlacedTower) {
const t = selectedPlacedTower;
if (t._btnUp && x>=t._btnUp.x && x<=t._btnUp.x+t._btnUp.w && y>=t._btnUp.y && y<=t._btnUp.y+t._btnUp.h) {
const upCost = getTowerCost(t.type) + (t.level === 0 ? 80 : 160);
if (gs.gold >= upCost && t.level < 2) {
gs.gold -= upCost; t.level++;
t.damage = TDEF[t.type].damage + t.level * 15;
t.range = TDEF[t.type].range + t.level * 20;
t.rate = TDEF[t.type].rate - t.level * 200;
t._btnUp = null;
}
return;
}
if (t._btnSell && x>=t._btnSell.x && x<=t._btnSell.x+t._btnSell.w && y>=t._btnSell.y && y<=t._btnSell.y+t._btnSell.h) {
gs.gold += Math.floor(TDEF[t.type].cost * 0.6);
gs.towers = gs.towers.filter(tt => tt !== t);
selectedPlacedTower = null;
return;
}
}
// 放置塔(吸附到方格中心)
if (selectedTowerType) {
const hover = getHoverCell(mouseX, mouseY);
if (!hover) {
if (mouseY < 400) showMsg('请将塔放置在方格内!');
return;
}
if (cellOccupied(hover.cx, hover.cy)) {
showMsg('该方格已有塔!'); return;
}
const t = createTower(selectedTowerType, hover.cx, hover.cy);
if (t) { gs.towers.push(t); gs.gold -= getTowerCost(selectedTowerType); }
selectedTowerType = null;
renderBottomBar();
return;
}
// 选中已放置的塔
if (!selectedTowerType) {
let found = null;
for (const t of gs.towers) {
if (dist(x,y,t.x,t.y) < 20) { found = t; break; }
}
selectedPlacedTower = (selectedPlacedTower === found) ? null : found;
}
});
canvas.addEventListener('mousemove', function(e) {
const { x, y } = screenToGame(e.clientX, e.clientY);
mouseX = x; mouseY = y;
});
document.addEventListener('keydown', function(e) {
if (e.code === 'Space') { gs.paused = !gs.paused; e.preventDefault(); }
});
window.toggleSpeed = function() {
gs.speed = gs.speed === 1 ? 2 : 1;
document.getElementById('speedBtn').textContent = gs.speed === 1 ? '▶ 正常' : '▶▶ 快进';
};
// ========== 游戏主循环 ==========
let lastTime = 0;
function gameLoop(timestamp) {
const dt = lastTime ? Math.min((timestamp - lastTime) / 1000, 0.05) : 0.016;
lastTime = timestamp;
if (!gs.paused) update(dt);
updateInfoBar();
draw();
requestAnimationFrame(gameLoop);
}
// ========== 启动 ==========
document.getElementById('startScreen').style.display = 'none';
renderLevelButtons();
setDifficulty('normal'); // 初始化难度按钮高亮
requestAnimationFrame(gameLoop);
// ========== 介绍界面 ==========
function enterLevelSelect() {
document.getElementById('introScreen').style.display = 'none';
document.getElementById('startScreen').style.display = 'flex';
}
// ========== 跳关系统 ==========
const SKIP_LEVELS = [
{ key:1, tag:'1-1', label:'酸碱中和', desc:'HCl · NaOH' },
{ key:2, tag:'1-2', label:'沉淀与置换', desc:'Fe · Ba(NO₃)₂' },
{ key:3, tag:'1-3', label:'氧化还原', desc:'KMnO₄ · C' },
{ key:4, tag:'2-1', label:'烯烃加成与醇的氧化', desc:'Br₂ · KMnO₄' },
{ key:5, tag:'2-2', label:'羧酸与酯的水解', desc:'NaOH · NaOH/H₂O' },
{ key:6, tag:'∞', label:'双线无尽挑战', desc:'全塔混合', endless:true }
];
function openSkipOverlay() {
const list = document.getElementById('skipLevelList');
list.innerHTML = '';
for (const lv of SKIP_LEVELS) {
const btn = document.createElement('button');
btn.className = 'skip-lvl-btn' + (lv.endless ? ' endless' : '');
btn.innerHTML =
'<span class="tag">' + lv.tag + '</span>' +
'<span style="flex:1">' + lv.label + '</span>' +
'<span style="font-size:11px;opacity:0.5;">' + lv.desc + '</span>';
btn.onclick = function() {
closeSkipOverlay();
// 确保游戏界面显示
document.getElementById('startScreen').style.display = 'none';
document.getElementById('introScreen').style.display = 'none';
document.getElementById('gameBox').style.display = 'flex';
if (lv.endless) {
initEndless();
} else {
initLevel(lv.key);
}
};
list.appendChild(btn);
}
document.getElementById('skipOverlay').classList.add('open');
}
function closeSkipOverlay() {
document.getElementById('skipOverlay').classList.remove('open');
}
</script>
</body>
</html>

浙公网安备 33010602011771号