index.html 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907
  1. <!DOCTYPE html>
  2. <html lang="zh">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>六边形拓扑 A* 寻路演示 (Hexagonal Topology A* Pathfinding)</title>
  7. <style>
  8. * {
  9. margin: 0;
  10. padding: 0;
  11. box-sizing: border-box;
  12. }
  13. body {
  14. font-family: 'Microsoft YaHei', 'Segoe UI', sans-serif;
  15. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  16. height: 100vh;
  17. overflow: hidden;
  18. padding: 10px;
  19. }
  20. .container {
  21. max-width: 100%;
  22. height: 100%;
  23. display: flex;
  24. flex-direction: column;
  25. }
  26. h1 {
  27. color: white;
  28. text-align: center;
  29. margin-bottom: 2px;
  30. font-size: 1.3em;
  31. text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
  32. }
  33. .subtitle {
  34. color: rgba(255, 255, 255, 0.9);
  35. text-align: center;
  36. margin-bottom: 3px;
  37. font-size: 0.65em;
  38. }
  39. .info-panel {
  40. background: white;
  41. border-radius: 8px;
  42. padding: 4px 8px;
  43. margin-bottom: 6px;
  44. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  45. width: 100%;
  46. display: flex;
  47. flex-direction: column;
  48. }
  49. .info-grid {
  50. display: grid;
  51. grid-template-columns: repeat(4, 1fr);
  52. gap: 4px;
  53. margin-bottom: 4px;
  54. }
  55. .info-card {
  56. background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
  57. color: white;
  58. padding: 3px 6px;
  59. border-radius: 5px;
  60. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  61. }
  62. .info-card h3 {
  63. margin-bottom: 1px;
  64. font-size: 0.65em;
  65. }
  66. .info-card p {
  67. font-size: 0.55em;
  68. line-height: 1.1;
  69. }
  70. .controls {
  71. display: flex;
  72. justify-content: center;
  73. gap: 10px;
  74. align-items: center;
  75. background: rgba(255, 255, 255, 0.95);
  76. padding: 8px 10px;
  77. border-radius: 6px;
  78. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
  79. width: 100%;
  80. flex-wrap: wrap;
  81. }
  82. .status {
  83. font-weight: 500;
  84. color: #fa0202;
  85. font-size: 0.75em;
  86. }
  87. .status-bar {
  88. background: rgba(255, 255, 255, 0.95);
  89. padding: 4px 10px;
  90. border-radius: 6px;
  91. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
  92. width: 100%;
  93. margin-bottom: 4px;
  94. display: flex;
  95. justify-content: center;
  96. align-items: center;
  97. }
  98. button {
  99. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  100. color: white;
  101. border: none;
  102. padding: 8px 20px;
  103. border-radius: 20px;
  104. font-size: 0.9em;
  105. cursor: pointer;
  106. transition: all 0.3s;
  107. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  108. font-weight: 500;
  109. }
  110. button:hover {
  111. transform: translateY(-1px);
  112. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
  113. }
  114. button:active {
  115. transform: translateY(0);
  116. }
  117. button:disabled {
  118. background: linear-gradient(135deg, #94a3b8 0%, #64748b 100%);
  119. cursor: not-allowed;
  120. transform: none;
  121. }
  122. button.active {
  123. background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
  124. box-shadow: 0 4px 12px rgba(245, 158, 11, 0.5);
  125. }
  126. .canvas-wrapper {
  127. background: white;
  128. border-radius: 10px;
  129. padding: 10px;
  130. box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
  131. flex: 1;
  132. display: flex;
  133. flex-direction: column;
  134. min-height: 0;
  135. }
  136. .canvas-container {
  137. overflow: auto;
  138. border: 2px solid #e2e8f0;
  139. border-radius: 8px;
  140. background: #f8fafc;
  141. flex: 1;
  142. }
  143. canvas {
  144. display: block;
  145. cursor: crosshair;
  146. }
  147. .legend {
  148. display: flex;
  149. justify-content: center;
  150. gap: 12px;
  151. margin-top: 8px;
  152. flex-wrap: wrap;
  153. }
  154. .legend-item {
  155. display: flex;
  156. align-items: center;
  157. gap: 6px;
  158. background: rgba(255, 255, 255, 0.9);
  159. padding: 4px 10px;
  160. border-radius: 15px;
  161. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
  162. font-size: 0.75em;
  163. }
  164. .legend-color {
  165. width: 18px;
  166. height: 18px;
  167. border-radius: 3px;
  168. border: 1.5px solid #333;
  169. }
  170. .instructions {
  171. background: rgba(102, 126, 234, 0.15);
  172. padding: 3px 6px;
  173. border-radius: 5px;
  174. }
  175. .instructions h4 {
  176. color: #1e293b;
  177. margin-bottom: 1px;
  178. font-size: 0.65em;
  179. }
  180. .instructions ul {
  181. list-style: none;
  182. padding-left: 0;
  183. margin: 0;
  184. }
  185. .instructions li {
  186. color: #1e293b;
  187. font-size: 0.55em;
  188. padding-left: 8px;
  189. position: relative;
  190. line-height: 1.2;
  191. }
  192. .instructions li:before {
  193. content: "▸";
  194. position: absolute;
  195. left: 0;
  196. color: #667eea;
  197. font-weight: bold;
  198. }
  199. /* Toast 提示框样式 */
  200. .toast {
  201. position: fixed;
  202. top: 25%;
  203. left: 50%;
  204. transform: translate(-50%, -50%) scale(0.8);
  205. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  206. color: white;
  207. padding: 20px 40px;
  208. border-radius: 12px;
  209. box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
  210. font-size: 1.1em;
  211. font-weight: 500;
  212. z-index: 10000;
  213. opacity: 0;
  214. transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
  215. min-width: 250px;
  216. text-align: center;
  217. pointer-events: none;
  218. }
  219. .toast.show {
  220. opacity: 1;
  221. transform: translate(-50%, -50%) scale(1);
  222. }
  223. .toast.success {
  224. background: linear-gradient(135deg, #10b981 0%, #059669 100%);
  225. }
  226. .toast.error {
  227. background: linear-gradient(135deg, #f43f5e 0%, #dc2626 100%);
  228. }
  229. </style>
  230. </head>
  231. <body>
  232. <div class="container">
  233. <h1>🎮 六边形拓扑 A* 寻路演示</h1>
  234. <p class="subtitle">Hexagonal Topology A* Pathfinding - 菱形格子 + 六方向移动</p>
  235. <div class="info-panel">
  236. <div class="info-grid">
  237. <div class="info-card">
  238. <h3>📐 网格类型</h3>
  239. <p><strong>列交错六边形</strong><br>
  240. 偶数列偏移形成六边形拓扑</p>
  241. </div>
  242. <div class="info-card">
  243. <h3>🎯 6方向移动</h3>
  244. <p><strong>6个邻居</strong>上下、左上左下、右上右下</p>
  245. </div>
  246. <div class="info-card">
  247. <h3>💎 菱形渲染</h3>
  248. <p><strong>等距投影</strong>45度旋转的美观展示</p>
  249. </div>
  250. <div class="instructions">
  251. <h4>📖 使用说明</h4>
  252. <ul>
  253. <li>左键:起点 | 右键:终点 | 按钮:寻路 | 规格:100x100网格</li>
  254. </ul>
  255. </div>
  256. </div>
  257. <div class="controls">
  258. <button id="findPathBtn">(老寻路)</button>
  259. <button id="findNewPathBtn">(新寻路)</button>
  260. <button id="copyPathBtn" disabled>复制路径</button>
  261. <button id="randomObstaclesBtn">随机生成障碍点</button>
  262. <button id="editObstaclesBtn">手动设置障碍点</button>
  263. <button id="toggleCoordsBtn">显示坐标</button>
  264. </div>
  265. <div class="status-bar">
  266. <div class="status" id="statusText">Ready - 左键设置起点,右键设置终点</div>
  267. </div>
  268. </div>
  269. <div class="canvas-wrapper">
  270. <div class="canvas-container">
  271. <canvas id="gridCanvas"></canvas>
  272. </div>
  273. <div class="legend">
  274. <div class="legend-item">
  275. <div class="legend-color" style="background: #10b981;"></div>
  276. <span>起点 (Start)</span>
  277. </div>
  278. <div class="legend-item">
  279. <div class="legend-color" style="background: #f43f5e;"></div>
  280. <span>终点 (End)</span>
  281. </div>
  282. <div class="legend-item">
  283. <div class="legend-color" style="background: #3b82f6;"></div>
  284. <span>路径 (Path)</span>
  285. </div>
  286. <div class="legend-item">
  287. <div class="legend-color" style="background: #334155;"></div>
  288. <span>障碍点 (Obstacle)</span>
  289. </div>
  290. <div class="legend-item">
  291. <div class="legend-color" style="background: rgba(245, 158, 11, 0.3);"></div>
  292. <span>偶数列 (Even Columns)</span>
  293. </div>
  294. <div class="legend-item">
  295. <div class="legend-color" style="background: rgba(139, 92, 246, 0.3);"></div>
  296. <span>奇数列 (Odd Columns)</span>
  297. </div>
  298. </div>
  299. </div>
  300. </div>
  301. <script>
  302. const canvas = document.getElementById('gridCanvas');
  303. const ctx = canvas.getContext('2d', { alpha: false });
  304. const findPathBtn = document.getElementById('findPathBtn');
  305. const findNewPathBtn = document.getElementById('findNewPathBtn');
  306. const copyPathBtn = document.getElementById('copyPathBtn');
  307. const statusText = document.getElementById('statusText');
  308. // Config
  309. const GRID_SIZE = 100;
  310. // Tight-packed hexagonal grid dimensions
  311. // For proper hexagonal packing: HEIGHT = WIDTH * sqrt(3)/2
  312. const TILE_WIDTH = 30;
  313. const TILE_HEIGHT = TILE_WIDTH * Math.sqrt(3) / 2; // 精确值 ≈ 25.98076
  314. // Canvas size: need to accommodate full grid range
  315. // Max X range: 0 to GRID_SIZE-1
  316. // Max Y range: 0 to GRID_SIZE-1
  317. // Screen X range: (0+0) to (99+99) = 0 to 198 half-widths
  318. // Screen Y range: (0-99) to (99-0) = -99 to 99 half-heights + colOffset
  319. const CANVAS_WIDTH = (GRID_SIZE * 2) * (TILE_WIDTH / 2) + TILE_WIDTH * 2;
  320. const CANVAS_HEIGHT = (GRID_SIZE * 2) * (TILE_HEIGHT / 2) + TILE_HEIGHT * 4;
  321. const OFFSET_X = TILE_WIDTH * 2;
  322. const OFFSET_Y = CANVAS_HEIGHT / 2;
  323. // Styles
  324. const COLORS = {
  325. bg: '#f8fafc',
  326. grid: '#94a3b8',
  327. start: '#10b981',
  328. end: '#f43f5e',
  329. path: '#3b82f6',
  330. obstacle: '#334155' // 障碍点颜色
  331. };
  332. // State
  333. let startPoint = null;
  334. let endPoint = null;
  335. let path = [];
  336. let obstacles = []; // 障碍点列表
  337. let showCoordinates = false;
  338. let isEditingObstacles = false; // 是否处于编辑障碍点模式
  339. // Toast 提示框功能
  340. // 功能:显示一个从底部向上漂浮的5秒后消失的提示框
  341. // 参数:message - 提示消息,type - 类型('success', 'error', 'info')
  342. function showToast(message, type = 'info') {
  343. // 创建 toast 元素
  344. const toast = document.createElement('div');
  345. toast.className = `toast ${type}`;
  346. toast.textContent = message;
  347. document.body.appendChild(toast);
  348. // 延迟显示,触发动画
  349. setTimeout(() => {
  350. toast.classList.add('show');
  351. }, 50);
  352. // 5秒后隐藏并移除
  353. setTimeout(() => {
  354. toast.classList.remove('show');
  355. setTimeout(() => {
  356. document.body.removeChild(toast);
  357. }, 200); // 等待动画完成后移除
  358. }, 2000);
  359. }
  360. // Setup Canvas
  361. canvas.width = CANVAS_WIDTH;
  362. canvas.height = CANVAS_HEIGHT;
  363. // Isometric Grid to Screen
  364. function gridToScreen(x, y) {
  365. // Offset so (1,1) is at the westernmost point
  366. const offsetX = x - 1;
  367. const offsetY = y - 1;
  368. // Column stagger: EVEN X columns shift
  369. if (x % 2 === 0) {
  370. // 向东北45度移动1.5格 = 在等距投影中 X方向+1.5, Y方向不变
  371. // 等距投影: screenX = (gridX + gridY) * W/2
  372. // 向东北移动1.5格: gridY += 1.5
  373. const neOffset = 1.5;
  374. // 垂直偏移(向下)
  375. const colOffsetY = TILE_HEIGHT / 2;
  376. // 水平偏移(向左,增加偏移量)
  377. const colOffsetX = -TILE_WIDTH / 2;
  378. // 东北偏移(向右上)
  379. const neOffsetX = neOffset * (TILE_WIDTH / 2);
  380. const neOffsetY = -neOffset * (TILE_HEIGHT / 2);
  381. const screenX = (offsetX + offsetY) * (TILE_WIDTH / 2) + OFFSET_X + colOffsetX + neOffsetX;
  382. const screenY = (offsetX - offsetY) * (TILE_HEIGHT / 2) + OFFSET_Y + colOffsetY + neOffsetY;
  383. return { x: screenX, y: screenY };
  384. } else {
  385. // 奇数列保持原位
  386. const screenX = (offsetX + offsetY) * (TILE_WIDTH / 2) + OFFSET_X;
  387. const screenY = (offsetX - offsetY) * (TILE_HEIGHT / 2) + OFFSET_Y;
  388. return { x: screenX, y: screenY };
  389. }
  390. }
  391. // Screen to Grid (approximate)
  392. function screenToGrid(sx, sy) {
  393. // Inverse isometric transformation (Y-inverted)
  394. const tempX = (sx - OFFSET_X) / (TILE_WIDTH / 2);
  395. const tempY = (sy - OFFSET_Y) / (TILE_HEIGHT / 2);
  396. let approxX = Math.round((tempX + tempY) / 2);
  397. let approxY = Math.round((tempX - tempY) / 2);
  398. // Distance-based refinement
  399. let minDist = Infinity;
  400. let closest = null;
  401. for (let dx = -2; dx <= 2; dx++) {
  402. for (let dy = -2; dy <= 2; dy++) {
  403. const cx = approxX + dx;
  404. const cy = approxY + dy;
  405. if (cx < 0 || cx >= GRID_SIZE || cy < 0 || cy >= GRID_SIZE) continue;
  406. const pos = gridToScreen(cx, cy);
  407. const dist = (sx - pos.x) ** 2 + (sy - pos.y) ** 2;
  408. if (dist < minDist) {
  409. minDist = dist;
  410. closest = { x: cx, y: cy };
  411. }
  412. }
  413. }
  414. return closest || { x: 0, y: 0 };
  415. }
  416. function drawDiamond(x, y, color) {
  417. const pos = gridToScreen(x, y);
  418. const HW = TILE_WIDTH / 2;
  419. const HH = TILE_HEIGHT / 2;
  420. ctx.beginPath();
  421. ctx.moveTo(pos.x, pos.y - HH); // Top
  422. ctx.lineTo(pos.x + HW, pos.y); // Right
  423. ctx.lineTo(pos.x, pos.y + HH); // Bottom
  424. ctx.lineTo(pos.x - HW, pos.y); // Left
  425. ctx.closePath();
  426. ctx.fillStyle = color;
  427. ctx.fill();
  428. ctx.strokeStyle = COLORS.grid;
  429. ctx.lineWidth = 0.5;
  430. ctx.stroke();
  431. }
  432. function clearCanvas() {
  433. ctx.fillStyle = COLORS.bg;
  434. ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
  435. // Draw all grid cells with column highlighting
  436. for (let y = 1; y <= GRID_SIZE; y++) {
  437. for (let x = 1; x <= GRID_SIZE; x++) {
  438. const pos = gridToScreen(x, y);
  439. const HW = TILE_WIDTH / 2;
  440. const HH = TILE_HEIGHT / 2;
  441. // Column highlighting: even columns (orange tint), odd columns (purple tint)
  442. let fillColor = (x % 2 === 0) ? 'rgba(245, 158, 11, 0.1)' : 'rgba(139, 92, 246, 0.1)';
  443. ctx.beginPath();
  444. ctx.moveTo(pos.x, pos.y - HH);
  445. ctx.lineTo(pos.x + HW, pos.y);
  446. ctx.lineTo(pos.x, pos.y + HH);
  447. ctx.lineTo(pos.x - HW, pos.y);
  448. ctx.closePath();
  449. ctx.fillStyle = fillColor;
  450. ctx.fill();
  451. ctx.strokeStyle = COLORS.grid;
  452. ctx.lineWidth = 0.5;
  453. ctx.stroke();
  454. }
  455. }
  456. // Draw coordinate labels based on showCoordinates flag
  457. if (showCoordinates) {
  458. ctx.fillStyle = '#1e293b';
  459. ctx.font = '6px monospace';
  460. ctx.textAlign = 'center';
  461. ctx.textBaseline = 'middle';
  462. for (let y = 1; y <= GRID_SIZE; y++) {
  463. for (let x = 1; x <= GRID_SIZE; x++) {
  464. const pos = gridToScreen(x, y);
  465. ctx.fillText(`${x},${y}`, pos.x, pos.y);
  466. }
  467. }
  468. }
  469. }
  470. function getNeighbors(x, y) {
  471. const isEvenCol = (x % 2 === 0);
  472. let dirs;
  473. if (isEvenCol) {
  474. // Even columns (shifted down)
  475. dirs = [
  476. { dx: 0, dy: -1, label: '4' }, // Up
  477. { dx: 0, dy: 1, label: '1' }, // Down
  478. { dx: 1, dy: 0, label: '3' }, // Right-Up
  479. { dx: 1, dy: 1, label: '2' }, // Right-Down
  480. { dx: -1, dy: 0, label: '5' }, // Left-Up
  481. { dx: -1, dy: 1, label: '6' } // Left-Down
  482. ];
  483. } else {
  484. // Odd columns (base position)
  485. dirs = [
  486. { dx: 0, dy: -1, label: '4' }, // Up
  487. { dx: 0, dy: 1, label: '1' }, // Down
  488. { dx: 1, dy: -1, label: '3' }, // Right-Up
  489. { dx: 1, dy: 0, label: '2' }, // Right-Down
  490. { dx: -1, dy: -1, label: '5' }, // Left-Up
  491. { dx: -1, dy: 0, label: '6' } // Left-Down
  492. ];
  493. }
  494. return dirs.map(d => ({
  495. x: x + d.dx,
  496. y: y + d.dy,
  497. label: d.label
  498. })).filter(n => n.x >= 0 && n.x < GRID_SIZE && n.y >= 0 && n.y < GRID_SIZE);
  499. }
  500. function redraw() {
  501. clearCanvas();
  502. // Draw Obstacles - 绘制障碍点
  503. if (obstacles.length > 0) {
  504. for (const obs of obstacles) {
  505. drawDiamond(obs.x, obs.y, COLORS.obstacle);
  506. }
  507. }
  508. // Draw Path as connected lines between cell centers
  509. if (path.length > 0) {
  510. ctx.strokeStyle = COLORS.path;
  511. ctx.lineWidth = 2.5;
  512. ctx.lineCap = 'round';
  513. ctx.lineJoin = 'round';
  514. ctx.beginPath();
  515. const firstPos = gridToScreen(path[0].x, path[0].y);
  516. ctx.moveTo(firstPos.x, firstPos.y);
  517. for (let i = 1; i < path.length; i++) {
  518. const pos = gridToScreen(path[i].x, path[i].y);
  519. ctx.lineTo(pos.x, pos.y);
  520. }
  521. ctx.stroke();
  522. }
  523. // Draw Start
  524. if (startPoint) {
  525. drawDiamond(startPoint.x, startPoint.y, COLORS.start);
  526. }
  527. // Draw End
  528. if (endPoint) {
  529. drawDiamond(endPoint.x, endPoint.y, COLORS.end);
  530. }
  531. }
  532. function drawLabel(x, y, text) {
  533. const pos = gridToScreen(x, y);
  534. ctx.fillStyle = '#1e293b';
  535. ctx.font = 'bold 12px Inter, sans-serif';
  536. ctx.textAlign = 'center';
  537. ctx.textBaseline = 'middle';
  538. ctx.fillText(text, pos.x, pos.y);
  539. }
  540. // Interaction
  541. function getGridPos(evt) {
  542. const rect = canvas.getBoundingClientRect();
  543. const scaleX = canvas.width / rect.width;
  544. const scaleY = canvas.height / rect.height;
  545. const clickX = (evt.clientX - rect.left) * scaleX;
  546. const clickY = (evt.clientY - rect.top) * scaleY;
  547. return screenToGrid(clickX, clickY);
  548. }
  549. // 鼠标点击事件处理
  550. // 功能:根据当前模式设置起点、终点或编辑障碍点
  551. // 左键:设置起点,右键:设置终点,点击时坐标立即更新
  552. canvas.addEventListener('mousedown', (e) => {
  553. const pos = getGridPos(e);
  554. if (pos.x < 0 || pos.x >= GRID_SIZE || pos.y < 0 || pos.y >= GRID_SIZE) return;
  555. // 如果处于编辑障碍点模式
  556. if (isEditingObstacles) {
  557. const key = `${pos.x},${pos.y}`;
  558. const obstacleIndex = obstacles.findIndex(obs => obs.x === pos.x && obs.y === pos.y);
  559. if (obstacleIndex !== -1) {
  560. // 已存在障碍点,删除它
  561. obstacles.splice(obstacleIndex, 1);
  562. statusText.textContent = `删除障碍点 (${pos.x}, ${pos.y})`;
  563. } else {
  564. // 不是起点和终点,添加障碍点
  565. const isStartOrEnd = (startPoint && startPoint.x === pos.x && startPoint.y === pos.y) ||
  566. (endPoint && endPoint.x === pos.x && endPoint.y === pos.y);
  567. if (!isStartOrEnd) {
  568. obstacles.push({ x: pos.x, y: pos.y });
  569. statusText.textContent = `添加障碍点 (${pos.x}, ${pos.y})`;
  570. } else {
  571. showToast('不能在起点或终点设置障碍点', 'error');
  572. }
  573. }
  574. // 清空当前路径
  575. path = [];
  576. copyPathBtn.disabled = true;
  577. redraw();
  578. } else {
  579. // 普通模式:设置起点和终点
  580. if (e.button === 0) {
  581. startPoint = pos;
  582. statusText.textContent = `起点: (${pos.x}, ${pos.y})`;
  583. } else if (e.button === 2) {
  584. e.preventDefault();
  585. endPoint = pos;
  586. statusText.textContent = `终点: (${pos.x}, ${pos.y})`;
  587. }
  588. path = [];
  589. copyPathBtn.disabled = true;
  590. redraw();
  591. }
  592. });
  593. canvas.addEventListener('contextmenu', (e) => e.preventDefault());
  594. // 通用寻路函数
  595. async function performPathfinding(endpoint, buttonElement) {
  596. if (!startPoint || !endPoint) {
  597. showToast('请先设置起点(左键)和终点(右键)', 'error');
  598. return;
  599. }
  600. buttonElement.disabled = true;
  601. statusText.textContent = "Finding path...";
  602. try {
  603. const response = await fetch(endpoint, {
  604. method: 'POST',
  605. headers: { 'Content-Type': 'application/json' },
  606. body: JSON.stringify({
  607. startX: startPoint.x,
  608. startY: startPoint.y,
  609. endX: endPoint.x,
  610. endY: endPoint.y,
  611. obstacles: obstacles // 传递障碍点列表
  612. })
  613. });
  614. if (!response.ok) {
  615. const err = await response.json();
  616. throw new Error(err.error || 'Unknown error');
  617. }
  618. const data = await response.json();
  619. if (data.error) {
  620. statusText.textContent = `Error: ${data.error}`;
  621. showToast(`错误: ${data.error}`, 'error');
  622. } else if (data.path && data.path.length > 0) {
  623. path = data.path;
  624. redraw();
  625. // 显示寻路成功提示
  626. showToast(`寻路成功!耗时:${data.costTime}ms, 路径长度: ${path.length}`, 'success');
  627. statusText.textContent = 'Ready - 左键设置起点,右键设置终点';
  628. copyPathBtn.disabled = false;
  629. } else {
  630. statusText.textContent = "No path found.";
  631. showToast('未找到路径!', 'error');
  632. copyPathBtn.disabled = true;
  633. }
  634. } catch (err) {
  635. console.error(err);
  636. statusText.textContent = "Error finding path.";
  637. showToast(`寻路错误: ${err.message}`, 'error');
  638. copyPathBtn.disabled = true; // 出错时禁用复制按钮
  639. } finally {
  640. buttonElement.disabled = false;
  641. // 注意:不要在这里无条件禁用copyPathBtn,因为它应该在成功时保持启用
  642. }
  643. }
  644. findPathBtn.addEventListener('click', async () => {
  645. await performPathfinding('/find-path', findPathBtn);
  646. });
  647. // 新寻路按钮事件处理 - 使用 astar6 算法
  648. findNewPathBtn.addEventListener('click', async () => {
  649. await performPathfinding('/find-path-astar6', findNewPathBtn);
  650. });
  651. // Toggle coordinates button
  652. const toggleCoordsBtn = document.getElementById('toggleCoordsBtn');
  653. toggleCoordsBtn.addEventListener('click', () => {
  654. showCoordinates = !showCoordinates;
  655. toggleCoordsBtn.textContent = showCoordinates ? '隐藏坐标' : '显示坐标';
  656. redraw();
  657. });
  658. // Copy path button - 复制路径按钮点击事件处理
  659. // 功能:将路径点列表转换为 {x,y} 格式并复制到剪贴板
  660. copyPathBtn.addEventListener('click', () => {
  661. if (path.length > 0) {
  662. // Convert path to string format: "{x1,y1} {x2,y2} {x3,y3} ..."
  663. const pathString = path.map(point => `{${point.x},${point.y}}`).join(' ');
  664. navigator.clipboard.writeText(pathString).then(() => {
  665. // 显示成功提示
  666. showToast('路径已复制到剪贴板!', 'success');
  667. }).catch(err => {
  668. console.error('Failed to copy path: ', err);
  669. showToast('复制失败,请手动选择复制', 'error');
  670. // Fallback: select text in alert
  671. alert(`复制失败,请手动复制以下内容:\n${pathString}`);
  672. });
  673. }
  674. });
  675. // Random obstacles button - 随机生成障碍点按钮点击事件处理
  676. // 功能:弹窗输入障碍点数量,生成随机障碍点并绘制
  677. const randomObstaclesBtn = document.getElementById('randomObstaclesBtn');
  678. randomObstaclesBtn.addEventListener('click', () => {
  679. const countStr = prompt('请输入要生成的障碍点数量(1-1000):', '50');
  680. if (countStr === null) return; // 用户取消
  681. const count = parseInt(countStr, 10);
  682. if (isNaN(count) || count < 1 || count > 1000) {
  683. showToast('请输入有效的数量(1-1000)', 'error');
  684. return;
  685. }
  686. // 生成随机障碍点
  687. obstacles = [];
  688. const existingPoints = new Set();
  689. // 添加起点和终点到已存在点集合,避免在这些位置生成障碍
  690. if (startPoint) existingPoints.add(`${startPoint.x},${startPoint.y}`);
  691. if (endPoint) existingPoints.add(`${endPoint.x},${endPoint.y}`);
  692. let attempts = 0;
  693. const maxAttempts = count * 10; // 最大尝试次数,避免死循环
  694. while (obstacles.length < count && attempts < maxAttempts) {
  695. const x = Math.floor(Math.random() * GRID_SIZE) + 1;
  696. const y = Math.floor(Math.random() * GRID_SIZE) + 1;
  697. const key = `${x},${y}`;
  698. if (!existingPoints.has(key)) {
  699. obstacles.push({ x, y });
  700. existingPoints.add(key);
  701. }
  702. attempts++;
  703. }
  704. // 清空当前路径
  705. path = [];
  706. copyPathBtn.disabled = true;
  707. redraw();
  708. showToast(`已生成 ${obstacles.length} 个障碍点`, 'success');
  709. statusText.textContent = 'Ready - 左键设置起点,右键设置终点';
  710. });
  711. // Edit obstacles button - 手动设置障碍点按钮点击事件处理
  712. // 功能:切换编辑障碍点模式,允许用户点击地图添加或删除障碍点
  713. const editObstaclesBtn = document.getElementById('editObstaclesBtn');
  714. editObstaclesBtn.addEventListener('click', () => {
  715. isEditingObstacles = !isEditingObstacles;
  716. if (isEditingObstacles) {
  717. editObstaclesBtn.classList.add('active');
  718. statusText.textContent = '编辑模式:点击地图添加/删除障碍点';
  719. showToast('已进入编辑障碍点模式', 'success');
  720. } else {
  721. editObstaclesBtn.classList.remove('active');
  722. statusText.textContent = 'Ready - 左键设置起点,右键设置终点';
  723. showToast('已退出编辑障碍点模式', 'info');
  724. }
  725. });
  726. // Initial draw
  727. redraw();
  728. // Mouse hover tooltip for coordinates
  729. let tooltip = null;
  730. function createTooltip() {
  731. if (!tooltip) {
  732. tooltip = document.createElement('div');
  733. tooltip.style.cssText = `
  734. position: absolute;
  735. background: rgba(30, 41, 59, 0.95);
  736. color: white;
  737. padding: 4px 8px;
  738. border-radius: 4px;
  739. font-size: 12px;
  740. pointer-events: none;
  741. z-index: 1000;
  742. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  743. `;
  744. document.body.appendChild(tooltip);
  745. }
  746. }
  747. function showTooltip(x, y, text) {
  748. createTooltip();
  749. tooltip.textContent = text;
  750. tooltip.style.left = x + 'px';
  751. tooltip.style.top = y + 'px';
  752. tooltip.style.display = 'block';
  753. }
  754. function hideTooltip() {
  755. if (tooltip) {
  756. tooltip.style.display = 'none';
  757. }
  758. }
  759. canvas.addEventListener('mousemove', (e) => {
  760. const rect = canvas.getBoundingClientRect();
  761. const scaleX = canvas.width / rect.width;
  762. const scaleY = canvas.height / rect.height;
  763. const mouseX = (e.clientX - rect.left) * scaleX;
  764. const mouseY = (e.clientY - rect.top) * scaleY;
  765. const gridPos = screenToGrid(mouseX, mouseY);
  766. if (gridPos.x >= 0 && gridPos.x < GRID_SIZE && gridPos.y >= 0 && gridPos.y < GRID_SIZE) {
  767. showTooltip(e.clientX + 10, e.clientY + 10, `(${gridPos.x}, ${gridPos.y})`);
  768. } else {
  769. hideTooltip();
  770. }
  771. });
  772. canvas.addEventListener('mouseleave', hideTooltip);
  773. // Auto-scroll to (1,1) position on load
  774. window.addEventListener('load', () => {
  775. const container = document.querySelector('.canvas-container');
  776. const pos = gridToScreen(1, 1);
  777. container.scrollLeft = pos.x - container.clientWidth / 2;
  778. container.scrollTop = pos.y - container.clientHeight / 2;
  779. });
  780. </script>
  781. </body>
  782. </html>