<template> <div class="pf-image"> <div class="controls"> <label>地图图片: <input type="file" accept="image/*" @change="onMapSelected" /> </label> <label>可选遮罩(黑=障碍): <input type="file" accept="image/*" @change="onMaskSelected" /> </label> <label>cellSize: <input type="number" v-model.number="cellSize" min="6" max="80" /> </label> <label>亮度阈值: <input type="range" v-model.number="lumThreshold" min="0" max="255" /> <span>{{ lumThreshold }}</span> </label> <label>遮罩占比阈值: <input type="range" v-model.number="maskRatio" min="0" max="1" step="0.05" /> <span>{{ maskRatio }}</span> </label> <button @click="regenerateGrid" :disabled="!mapLoaded">生成 Grid</button> <button @click="clearObstacles" :disabled="!gridReady">清空障碍</button> <button @click="addExampleObstacles" :disabled="!gridReady">示例障碍</button> <button @click="stopAnimation">停止</button> <span class="hint">点击图片设目标;Shift+拖绘制障碍。目标会自动靠近最近可走点。</span> </div> <div class="canvas-wrap" v-if="mapLoaded"> <!-- canvas 实际像素尺寸等于图片像素,样式宽度受 displayWidth 控制 --> <canvas ref="canvas" @click="onCanvasClick" @dblclick="onCanvasDblClick" @mousedown="onMouseDown" @mousemove="onMouseMove" @mouseup="onMouseUp" :style="{ width: displayWidth + 'px' }"></canvas> </div> <div v-else class="no-map">请先上传地图图片。</div> <div class="info" v-if="mapLoaded"> <div>地图: {{ mapWidth }} × {{ mapHeight }} px</div> <div>格子: {{ cols }} × {{ rows }} (cellSize={{ cellSize }})</div> <div>路径点: {{ pathPixels.length }} ,动画中: {{ animating }}</div> </div> </div> </template> <script> /* PathfindingImage.vue - 在图片上进行寻路(支持遮罩或基于亮度自动生成障碍) - Vue 2 Options API */ export default { name: 'PathfindingImage', data() { return { // 图片与 canvas mapImg: null, maskImg: null, mapWidth: 0, mapHeight: 0, displayWidth: 900, // 页面上显示的最大宽度(可调整) // grid 参数 cellSize: 20, lumThreshold: 100, maskRatio: 0.5, // grid 状态 grid: null, rows: 0, cols: 0, obstacles: new Set(), gridReady: false, // agent / path agent: { x: 0, y: 0, speed: 220 }, pathPixels: [], targetPx: null, animating: false, // animation internals _rafId: null, _lastTs: 0, _moveIndex: 0, _lastReplanAt: 0, _replanInterval: 200, // mouse drawing isDrawing: false, drawStart: null, drawEnd: null, drawModeAdd: true }; }, computed: { mapLoaded() { return !!this.mapImg; } }, mounted() { window.addEventListener('resize', this._onResize); }, beforeDestroy() { window.removeEventListener('resize', this._onResize); this.stopAnimation(); }, methods: { // ---------- 图片加载 ---------- onMapSelected(e) { const f = e.target.files && e.target.files[0]; if (!f) return; const url = URL.createObjectURL(f); const img = new Image(); img.onload = () => { this.mapImg = img; this.mapWidth = img.naturalWidth; this.mapHeight = img.naturalHeight; // 设置 canvas 实际像素尺寸为图片尺寸 const canvas = this.$refs.canvas; if (canvas) { canvas.width = this.mapWidth; canvas.height = this.mapHeight; this.displayWidth = Math.min(this.mapWidth, 1100); } // place agent at center this.agent.x = this.mapWidth / 4; this.agent.y = this.mapHeight / 2; this.initGrid(); // init empty grid based on default cellSize this.redraw(); }; img.src = url; }, onMaskSelected(e) { const f = e.target.files && e.target.files[0]; if (!f) return; const url = URL.createObjectURL(f); const img = new Image(); img.onload = () => { this.maskImg = img; }; img.src = url; }, // ---------- grid 生成 ---------- regenerateGrid() { if (!this.mapImg) return; this.initGrid(); this.obstacles.clear(); if (this.maskImg) { this._generateGridFromMask(); } else { this._generateGridFromBrightness(); } this.gridReady = true; // ensure agent/given start is on walkable const near = this.findNearestWalkable(this.agent.x, this.agent.y); if (near) { this.agent.x = near.x; this.agent.y = near.y; } this.targetPx = null; this.pathPixels = []; this.redraw(); }, initGrid() { if (!this.mapImg) return; this.cols = Math.ceil(this.mapWidth / this.cellSize); this.rows = Math.ceil(this.mapHeight / this.cellSize); this.grid = new Array(this.rows); for (let r = 0; r < this.rows; r++) { this.grid[r] = new Array(this.cols).fill(true); } this.obstacles.clear(); this.gridReady = false; }, _generateGridFromMask() { // draw mask to offscreen canvas resized to map size (mask may differ in size) const c = document.createElement('canvas'); c.width = this.mapWidth; c.height = this.mapHeight; const ctx = c.getContext('2d'); ctx.drawImage(this.maskImg, 0, 0, this.mapWidth, this.mapHeight); const data = ctx.getImageData(0, 0, this.mapWidth, this.mapHeight).data; for (let r = 0; r < this.rows; r++) { for (let ccol = 0; ccol < this.cols; ccol++) { const x0 = ccol * this.cellSize; const y0 = r * this.cellSize; let blackCount = 0, total = 0; for (let yy = y0; yy < Math.min(this.mapHeight, y0 + this.cellSize); yy++) { for (let xx = x0; xx < Math.min(this.mapWidth, x0 + this.cellSize); xx++) { const idx = (yy * this.mapWidth + xx) * 4; const rr = data[idx], gg = data[idx + 1], bb = data[idx + 2], aa = data[idx + 3]; const lum = 0.299 * rr + 0.587 * gg + 0.114 * bb; // treat non-transparent dark pixels as mask if (aa > 10 && lum < 128) blackCount++; total++; } } const ratio = total ? (blackCount / total) : 0; const walkable = ratio < this.maskRatio; this.grid[r][ccol] = walkable; if (!walkable) this.obstacles.add(`${r},${ccol}`); } } }, _generateGridFromBrightness() { const c = document.createElement('canvas'); c.width = this.mapWidth; c.height = this.mapHeight; const ctx = c.getContext('2d'); ctx.drawImage(this.mapImg, 0, 0, this.mapWidth, this.mapHeight); const data = ctx.getImageData(0, 0, this.mapWidth, this.mapHeight).data; for (let r = 0; r < this.rows; r++) { for (let ccol = 0; ccol < this.cols; ccol++) { const x0 = ccol * this.cellSize; const y0 = r * this.cellSize; let darkCount = 0, total = 0; for (let yy = y0; yy < Math.min(this.mapHeight, y0 + this.cellSize); yy++) { for (let xx = x0; xx < Math.min(this.mapWidth, x0 + this.cellSize); xx++) { const idx = (yy * this.mapWidth + xx) * 4; const rr = data[idx], gg = data[idx + 1], bb = data[idx + 2]; const lum = 0.299 * rr + 0.587 * gg + 0.114 * bb; if (lum < this.lumThreshold) darkCount++; total++; } } const ratio = total ? (darkCount / total) : 0; const walkable = ratio < this.maskRatio; this.grid[r][ccol] = walkable; if (!walkable) this.obstacles.add(`${r},${ccol}`); } } }, // ---------- 交互:点击 / 绘制 ---------- onCanvasClick(e) { if (e.shiftKey) return; // 绘制优先 if (!this.gridReady) { // 若 grid 未生成,先尝试生成(基于亮度) this.regenerateGrid(); if (!this.gridReady) return; } const pos = this._getMousePos(e); const nt = this.findNearestWalkable(pos.x, pos.y); if (!nt) { this.pathPixels = []; this.targetPx = null; this.redraw(); return; } this.targetPx = nt; const startCell = this.pixelToCell(this.agent.x, this.agent.y); const endCell = this.pixelToCell(this.targetPx.x, this.targetPx.y); // ensure start & end walkable if (!this.grid[startCell.r][startCell.c]) { const near = this.findNearestWalkable(this.agent.x, this.agent.y); if (!near) { this.pathPixels = []; this.redraw(); return; } this.agent.x = near.x; this.agent.y = near.y; } if (!this.grid[endCell.r][endCell.c]) { const near = this.findNearestWalkable(this.targetPx.x, this.targetPx.y); if (!near) { this.pathPixels = []; this.redraw(); return; } this.targetPx = near; } const cellPath = this.findPathAStar(this.grid, this.rows, this.cols, startCell, endCell); if (!Array.isArray(cellPath) || cellPath.length === 0) { this.pathPixels = []; this.redraw(); return; } // convert to pixel centers and safe-check const MAX_PATH = 20000; const safe = cellPath.length > MAX_PATH ? cellPath.slice(0, MAX_PATH) : cellPath; const pixels = []; for (let i = 0; i < safe.length; i++) { const n = safe[i]; if (!n || typeof n.r !== 'number' || typeof n.c !== 'number') continue; pixels.push(this.cellCenter(n.r, n.c)); } if (pixels.length === 0) { this.pathPixels = []; this.redraw(); return; } this.pathPixels = this.smoothPath(pixels); if (!Array.isArray(this.pathPixels) || this.pathPixels.length === 0) { // fallback to raw pixels this.pathPixels = pixels; } this.startAnimation(); }, onCanvasDblClick() { this.targetPx = null; this.pathPixels = []; this.stopAnimation(); this.redraw(); }, onMouseDown(e) { if (!e.shiftKey || !this.gridReady) return; this.isDrawing = true; this.drawStart = this._getMousePos(e); this.drawEnd = null; this.drawModeAdd = !e.ctrlKey; }, onMouseMove(e) { if (!this.isDrawing) return; this.drawEnd = this._getMousePos(e); // preview this.redraw(); }, onMouseUp(e) { if (!this.isDrawing) return; this.isDrawing = false; this.drawEnd = this._getMousePos(e); // apply rectangle to grid const x1 = Math.min(this.drawStart.x, this.drawEnd.x); const x2 = Math.max(this.drawStart.x, this.drawEnd.x); const y1 = Math.min(this.drawStart.y, this.drawEnd.y); const y2 = Math.max(this.drawStart.y, this.drawEnd.y); const c1 = Math.floor(x1 / this.cellSize), c2 = Math.floor(x2 / this.cellSize); const r1 = Math.floor(y1 / this.cellSize), r2 = Math.floor(y2 / this.cellSize); for (let r = r1; r <= r2; r++) { for (let c = c1; c <= c2; c++) { if (r < 0 || r >= this.rows || c < 0 || c >= this.cols) continue; if (this.drawModeAdd) { this.grid[r][c] = false; this.obstacles.add(`${r},${c}`); } else { this.grid[r][c] = true; this.obstacles.delete(`${r},${c}`); } } } // replan if animating this._lastReplanAt = 0; if (this.animating) this._checkDynamicObstacle(); this.redraw(); }, // ---------- 寻路(A*) ---------- findPathAStar(grid, rows, cols, start, end) { if (!this._inBounds(start.r, start.c) || !this._inBounds(end.r, end.c)) return null; if (!grid[start.r][start.c] || !grid[end.r][end.c]) return null; const key = (r, c) => `${r},${c}`; const heap = new MinHeap(); const g = new Map(), f = new Map(), came = new Map(); const closed = new Set(); const h = (a, b) => { const dx = Math.abs(a.c - b.c), dy = Math.abs(a.r - b.r); const D = 1, D2 = Math.SQRT2; return D * (dx + dy) + (D2 - 2 * D) * Math.min(dx, dy); }; const sKey = key(start.r, start.c), eKey = key(end.r, end.c); g.set(sKey, 0); f.set(sKey, h(start, end)); heap.push({ r: start.r, c: start.c, f: f.get(sKey) }); while (heap.size()) { const cur = heap.pop(); const curKey = key(cur.r, cur.c); if (closed.has(curKey)) continue; if (curKey === eKey) { const path = []; let k = curKey; while (k) { const [rr, cc] = k.split(',').map(Number); path.push({ r: rr, c: cc }); k = came.get(k); } path.reverse(); return path; } closed.add(curKey); const del = [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[-1,1],[1,-1],[1,1]]; for (const d of del) { const nr = cur.r + d[0], nc = cur.c + d[1]; if (!this._inBounds(nr, nc)) continue; if (!grid[nr][nc]) continue; if (Math.abs(d[0]) + Math.abs(d[1]) === 2) { if (!grid[cur.r + d[0]][cur.c] || !grid[cur.r][cur.c + d[1]]) continue; } const nbKey = key(nr, nc); if (closed.has(nbKey)) continue; const tentativeG = g.get(curKey) + ((nr === cur.r || nc === cur.c) ? 1 : Math.SQRT2); if (g.get(nbKey) === undefined || tentativeG < g.get(nbKey)) { came.set(nbKey, curKey); g.set(nbKey, tentativeG); f.set(nbKey, tentativeG + h({ r: nr, c: nc }, end)); heap.push({ r: nr, c: nc, f: f.get(nbKey) }); } } } return null; }, _inBounds(r, c) { return r >= 0 && r < this.rows && c >= 0 && c < this.cols; }, // ---------- 平滑 & LOS ---------- smoothPath(points) { if (!Array.isArray(points)) return []; const MAX = 20000; let n = points.length; if (n === 0) return []; if (n > MAX) points = points.slice(0, MAX), n = points.length; for (let i = 0; i < n; i++) { const p = points[i]; if (!p || typeof p.x !== 'number' || typeof p.y !== 'number') { points = points.slice(0, i); n = points.length; break; } } if (n <= 2) return points.slice(); const out = []; let i = 0; while (i < n) { out.push(points[i]); let found = false; for (let j = n - 1; j > i; j--) { if (this.lineOfSight(points[i], points[j])) { i = j; found = true; break; } } if (!found) i = i + 1; } return out; }, lineOfSight(a, b) { const dx = b.x - a.x, dy = b.y - a.y; const dist = Math.hypot(dx, dy); const step = Math.max(1, this.cellSize / 4); const steps = Math.ceil(dist / step); for (let i = 0; i <= steps; i++) { const t = i / steps; const x = a.x + dx * t, y = a.y + dy * t; const cell = this.pixelToCell(x, y); if (!this.grid[cell.r][cell.c]) return false; } return true; }, // ---------- 动画 ---------- startAnimation() { if (!Array.isArray(this.pathPixels) || this.pathPixels.length < 2) return; if (Math.hypot(this.pathPixels[0].x - this.agent.x, this.pathPixels[0].y - this.agent.y) > 1) { this.pathPixels.unshift({ x: this.agent.x, y: this.agent.y }); } this._moveIndex = 1; this.animating = true; this._lastTs = performance.now(); if (this._rafId) cancelAnimationFrame(this._rafId); this._rafId = requestAnimationFrame(this._animateStep); }, _animateStep(ts) { if (!this.animating) return; const dt = (ts - this._lastTs) / 1000; this._lastTs = ts; if (!this.pathPixels || this._moveIndex >= this.pathPixels.length) { this.animating = false; this.redraw(); return; } const target = this.pathPixels[this._moveIndex]; const dx = target.x - this.agent.x, dy = target.y - this.agent.y; const dist = Math.hypot(dx, dy); if (dist < 1) { this._moveIndex++; } else { const vx = (dx / dist) * this.agent.speed; const vy = (dy / dist) * this.agent.speed; const stepX = vx * dt, stepY = vy * dt; if (Math.hypot(stepX, stepY) >= dist) { this.agent.x = target.x; this.agent.y = target.y; this._moveIndex++; } else { this.agent.x += stepX; this.agent.y += stepY; } } // throttle replan const now = performance.now(); if (now - this._lastReplanAt > this._replanInterval) { this._lastReplanAt = now; this._checkDynamicObstacle(); } this.redraw(); this._rafId = requestAnimationFrame(this._animateStep); }, stopAnimation() { this.animating = false; if (this._rafId) { cancelAnimationFrame(this._rafId); this._rafId = null; } }, _checkDynamicObstacle() { if (!this.pathPixels || this.pathPixels.length === 0) return; const lookAhead = 3; const startIdx = Math.max(this._moveIndex, 0); for (let i = startIdx; i < Math.min(this.pathPixels.length, startIdx + lookAhead); i++) { const p = this.pathPixels[i]; const cell = this.pixelToCell(p.x, p.y); if (!this.grid[cell.r][cell.c]) { this._handleDynamicBlock(); break; } } }, _handleDynamicBlock() { if (!this.targetPx) { this.animating = false; return; } const startCell = this.pixelToCell(this.agent.x, this.agent.y); if (!this.grid[startCell.r][startCell.c]) { const near = this.findNearestWalkable(this.agent.x, this.agent.y); if (!near) { this.animating = false; return; } this.agent.x = near.x; this.agent.y = near.y; } const newCells = this.findPathAStar(this.grid, this.rows, this.cols, this.pixelToCell(this.agent.x, this.agent.y), this.pixelToCell(this.targetPx.x, this.targetPx.y)); if (!newCells) { this.animating = false; this.pathPixels = []; return; } let newPx = newCells.map(n => this.cellCenter(n.r, n.c)); newPx = this.smoothPath(newPx); this.pathPixels = newPx; this._moveIndex = 1; this.animating = true; }, // ---------- 绘制 ---------- redraw() { const canvas = this.$refs.canvas; if (!canvas || !this.mapImg) return; const ctx = canvas.getContext('2d'); // draw map (base) ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(this.mapImg, 0, 0, canvas.width, canvas.height); // obstacles overlay if (this.gridReady) { ctx.save(); ctx.globalAlpha = 0.6; ctx.fillStyle = 'rgba(200,50,50,0.6)'; for (const key of this.obstacles) { const [r, c] = key.split(',').map(Number); ctx.fillRect(c * this.cellSize, r * this.cellSize, this.cellSize, this.cellSize); } ctx.restore(); } // draw path if (this.pathPixels && this.pathPixels.length) { ctx.save(); ctx.strokeStyle = 'rgba(30,144,255,0.95)'; ctx.lineWidth = Math.max(2, this.cellSize / 8); ctx.beginPath(); ctx.moveTo(this.pathPixels[0].x, this.pathPixels[0].y); for (let i = 1; i < this.pathPixels.length; i++) ctx.lineTo(this.pathPixels[i].x, this.pathPixels[i].y); ctx.stroke(); ctx.restore(); } // draw target if (this.targetPx) { ctx.save(); ctx.fillStyle = 'red'; ctx.beginPath(); ctx.arc(this.targetPx.x, this.targetPx.y, Math.max(6, this.cellSize / 4), 0, Math.PI * 2); ctx.fill(); ctx.restore(); } // draw agent ctx.save(); ctx.fillStyle = 'orange'; ctx.beginPath(); ctx.arc(this.agent.x, this.agent.y, Math.max(8, this.cellSize / 3), 0, Math.PI * 2); ctx.fill(); ctx.restore(); // drawing preview if (this.isDrawing && this.drawStart && this.drawEnd) { ctx.save(); ctx.strokeStyle = this.drawModeAdd ? 'rgba(255,0,0,0.9)' : 'rgba(0,200,0,0.9)'; ctx.lineWidth = 2; ctx.strokeRect(Math.min(this.drawStart.x, this.drawEnd.x), Math.min(this.drawStart.y, this.drawEnd.y), Math.abs(this.drawStart.x - this.drawEnd.x), Math.abs(this.drawStart.y - this.drawEnd.y)); ctx.restore(); } }, // ---------- 坐标映射 / 辅助 ---------- _getMousePos(e) { const canvas = this.$refs.canvas; const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; return { x, y }; }, pixelToCell(x, y) { const c = Math.floor(x / this.cellSize); const r = Math.floor(y / this.cellSize); return { r: Math.max(0, Math.min(this.rows - 1, r)), c: Math.max(0, Math.min(this.cols - 1, c)) }; }, cellCenter(r, c) { return { x: c * this.cellSize + this.cellSize / 2, y: r * this.cellSize + this.cellSize / 2 }; }, findNearestWalkable(px, py) { if (!this.grid) return null; const start = this.pixelToCell(px, py); if (this.grid[start.r][start.c]) return { x: px, y: py }; const q = [start]; const visited = new Set([`${start.r},${start.c}`]); const del = [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[-1,1],[1,-1],[1,1]]; while (q.length) { const cur = q.shift(); for (const d of del) { const nr = cur.r + d[0], nc = cur.c + d[1]; if (nr < 0 || nr >= this.rows || nc < 0 || nc >= this.cols) continue; const k = `${nr},${nc}`; if (visited.has(k)) continue; visited.add(k); if (this.grid[nr][nc]) return this.cellCenter(nr, nc); q.push({ r: nr, c: nc }); } } return null; }, // ---------- UI helpers ---------- clearObstacles() { if (!this.grid) return; this.obstacles.clear(); for (let r = 0; r < this.rows; r++) for (let c = 0; c < this.cols; c++) this.grid[r][c] = true; this.gridReady = true; this.redraw(); }, addExampleObstacles() { if (!this.grid) return; this.obstacles.clear(); for (let r = Math.floor(this.rows * 0.25); r < Math.min(this.rows, Math.floor(this.rows * 0.25) + 8); r++) { for (let c = Math.floor(this.cols * 0.2); c < Math.min(this.cols, Math.floor(this.cols * 0.2) + 12); c++) { this.grid[r][c] = false; this.obstacles.add(`${r},${c}`); } } this.redraw(); }, // ---------- resize handler ---------- _onResize() { if (!this.mapImg) return; this.displayWidth = Math.min(this.mapImg.naturalWidth, 1100); } } }; // ---------- MinHeap ---------- class MinHeap { constructor() { this.data = []; } push(item) { this.data.push(item); this._siftUp(this.data.length - 1); } pop() { if (!this.data.length) return null; const top = this.data[0]; const last = this.data.pop(); if (this.data.length) { this.data[0] = last; this._siftDown(0); } return top; } size() { return this.data.length; } _siftUp(i) { const a = this.data; while (i > 0) { const p = Math.floor((i - 1) / 2); if (a[i].f >= a[p].f) break; [a[i], a[p]] = [a[p], a[i]]; i = p; } } _siftDown(i) { const a = this.data, n = a.length; while (true) { let l = 2 * i + 1, r = 2 * i + 2, s = i; if (l < n && a[l].f < a[s].f) s = l; if (r < n && a[r].f < a[s].f) s = r; if (s === i) break; [a[i], a[s]] = [a[s], a[i]]; i = s; } } } </script> <style scoped> .pf-image { font-family: Arial, Helvetica, sans-serif; max-width: 1200px; } .controls { display:flex; gap:10px; align-items:center; margin-bottom:10px; flex-wrap:wrap; } .controls label { display:flex; align-items:center; gap:6px; } .canvas-wrap { border:1px solid #ddd; display:inline-block; } canvas { display:block; max-width:100%; height:auto; cursor:crosshair; } .info { margin-top:8px; color:#333; } .hint { color:#666; font-size:0.9em; margin-left:8px; } .no-map { color:#666; margin-top:8px; } </style>
news
2026/3/26 20:02:48
自动寻路完整版本demo(可上传图片版本)
张小明
前端开发工程师
1.2k
24
版权声明:
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!