文章目录
- 前言
- 一、整体逻辑以及使用方法?
- 二、数据模型
- 三、设置canvas尺寸
- 四、铺格子地图
- 总结(第一段代码)
- 五、每一帧把整个地图画出来
-
- 5.1清空画布
- [5.2 绘制永久障碍物(黑色)](#5.2 绘制永久障碍物(黑色))
- 5.3绘制网格线(可选)
- 5.4绘制路径(蓝线)
- 5.5绘制目标点(红色圆)
- [5.6绘制绘制工具的预览(矩形 / 笔刷)](#5.6绘制绘制工具的预览(矩形 / 笔刷))
-
- 矩形工具
- [笔刷 / 橡皮](#笔刷 / 橡皮)
- [总结 redraw()](#总结 redraw())
- [六 上传背景图](#六 上传背景图)
- [七 点击画布后根据工具不同做出对应操作](#七 点击画布后根据工具不同做出对应操作)
-
-
- 7.1获取鼠标点击位置
- [7.2Brush / Erase 工具单击](#7.2Brush / Erase 工具单击)
- 7.2点击格子处理(目标点)
-
- 7.3起点格子处理(角色当前位置)
- 7.4寻路
- 7.5
- 7.5将格子路径转成像素路径
- [7.6 this.startAnimation();](#7.6 this.startAnimation();)
- [7.7BFS 最近点搜索算法](#7.7BFS 最近点搜索算法)
- [7.8A* 寻路算法](#7.8A* 寻路算法)
- 总结
-
- 8.不想看前面直接点击这里(直接复制完整代码)
前言
此文章包含手动选择添加障碍物、删除障碍物、支持画笔和矩形绘制、可调节画笔粗细、上传背景图片、随机障碍物、导出障碍物坐标、复制障碍物坐标、停止小球运动功能。
一、整体逻辑以及使用方法?
这个组件是基于平面图障碍编辑以及A*算法寻路演示、平面图背景上传、用格子cellsize划分可行走网格可用画笔编辑临时障碍物和永久障碍物点击画布可做寻路结果导出直接写死或者给后端储存数据库。
二、数据模型
1.(data里面的关键字段)
bgSrc / imgNaturalWidth / displayWidth:背景图片与显示尺寸。
width, height:canvas 的内部像素尺寸(与 display 一致,组件把 canvas.width 设为 displayWidth)。
cellSize:每格像素大小(网格分辨率)。
cols, rows:网格列数/行数(由 width/height/cellSize 计算)。
grid:二维布尔数组 grid[r][c],true 可走,false 障碍(运行时从 permanentObstacles/obstacles 写回)。
obstacles:Set,临时障碍,元素为 "r,c"。
permanentObstacles:Set,永久障碍,元素为 "r,c"。
agent:当前示例 agent 的像素位置 {x,y,speed}(用于动画)。
path:像素点数组 [{x,y},...],是 A* 返回的格子中心经过可能的平滑后用于动画。
tool, brushSize, drawModeAdd:编辑器状态(画笔/橡皮/矩形/点击、笔刷粗细、矩形添加或删除)。
exportText:导出文本供用户复制。
三、设置canvas尺寸
通俗形容 canvas 有"两层"
就像你有一本透明的画板:
底层是实际画画的纸(真实大小)
上面是你眼睛看到的透明塑料膜(显示大小)
浏览器经常会自动把"给你看的膜"缩放,而不缩放"实际纸",就会产生错位
让这两层 完全一样大
否则:
你以为在点某个格子
实际点到的是另一个格子
导出的障碍坐标就完全不准
javascript
cvs.width = this.width; // 真实纸张的宽
cvs.height = this.height; // 真实纸张的高
cvs.style.width = this.width + 'px'; // 给你看的纸张宽
cvs.style.height = this.height + 'px'; // 给你看的纸张高
四、铺格子地图
javascript
initGrid() {
this.cols = Math.ceil(this.width / this.cellSize);
this.rows = Math.ceil(this.height / 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);
for (const key of this.permanentObstacles) {
const [r, c] = key.split(',').map(Number);
if (this._inBounds(r, c)) this.grid[r][c] = false;
}
for (const key of this.obstacles) {
const [r, c] = key.split(',').map(Number);
const k = `${r},${c}`;
if (this._inBounds(r, c) && !this.permanentObstacles.has(k)) this.grid[r][c] = false;
}
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.agent.x = near.x; this.agent.y = near.y; }
else { const c = this.cellCenter(0,0); this.agent.x = c.x; this.agent.y = c.y; }
}
},
解析
this.cols = Math.ceil(this.width / this.cellSize);
this.rows = Math.ceil(this.height / this.cellSize);
画布宽 width = 900px
每个格子 cellSize = 8px
一行能放 900 / 8 ≈ 112.5 个格子 不够的要补一个,所以用 Math.ceil
结果:
cols = 横向格子数
rows = 纵向格子数
javascript
this.grid = new Array(this.rows);
for (let r = 0; r < this.rows; r++) {
this.grid[r] = new Array(this.cols).fill(true);
}
这一步在干嘛?
造一个二维数组:
grid[行][列]
每个格子一个 true / false
true 👉 能走
false 👉 障碍物
刚创建时:
假设整张地图都是空地(全 true)
javascript
for (const key of this.permanentObstacles) {
const [r, c] = key.split(',').map(Number);
if (this._inBounds(r, c)) this.grid[r][c] = false;
}
这段代码在做什么?
逐个拿出这些坐标:
19,51
↓
r = 19
c = 51
然后:
grid[19][51] = false;
也就是说:
这些格子永远是墙,不能走
permanentObstacles 是什么?
new Set([ '19,51', '19,52', '19,53' ])每一项代表一个格子坐标
javascript
const startCell = this.pixelToCell(this.agent.x, this.agent.y);
这一步做了啥?
agent.x / y 是像素坐标
pixelToCell 把它变成:
{ r: 行号, c: 列号 }
如果角色出生在障碍里,怎么办?
if (!this.grid[startCell.r][startCell.c]) {
角色当前站的位置是 false(墙)
那就要救他。
先找最近的能走格子
const near = this.findNearestWalkable(this.agent.x, this.agent.y);
BFS 找最近的 true 格子
找到就把角色移过去
this.agent.x = near.x;
this.agent.y = near.y;
如果实在找不到(极端情况)
else {
const c = this.cellCenter(0,0);
this.agent.x = c.x;
this.agent.y = c.y;
}
总结(第一段代码)
根据画布大小 → 生成格子地图 → 把永久/临时障碍同步进去 → 保证角色不在墙里
javascript
画布像素
↓
切成格子
↓
grid[r][c] = true / false
↓
A* / 点击 / 绘制 全都只认 grid
这一段可以删除临时障碍和位置定位在障碍物上的判断
五、每一帧把整个地图画出来
javascript
redraw() {
const canvas = this.$refs.canvas;
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
// draw temporary obstacles (red)
ctx.save();
ctx.globalAlpha = 0.9;
ctx.fillStyle = '#b33';
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 permanent obstacles (black)
ctx.save();
ctx.fillStyle = '#111';
for (const key of this.permanentObstacles) {
const [r, c] = key.split(',').map(Number);
ctx.fillRect(c * this.cellSize, r * this.cellSize, this.cellSize, this.cellSize);
}
ctx.restore();
// optional: grid lines
ctx.save();
ctx.strokeStyle = 'rgba(0,0,0,0.06)';
for (let c = 0; c <= this.cols; c++) {
const x = c * this.cellSize;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, this.height); ctx.stroke();
}
for (let r = 0; r <= this.rows; r++) {
const y = r * this.cellSize;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(this.width, y); ctx.stroke();
}
ctx.restore();
// path
if (this.path && this.path.length) {
ctx.save();
ctx.strokeStyle = '#1976d2';
ctx.lineWidth = Math.max(2, this.cellSize / 8);
ctx.beginPath();
ctx.moveTo(this.path[0].x, this.path[0].y);
for (let i = 1; i < this.path.length; i++) ctx.lineTo(this.path[i].x, this.path[i].y);
ctx.stroke();
ctx.restore();
}
// target
if (this.target) {
ctx.save();
ctx.fillStyle = 'red';
ctx.beginPath(); ctx.arc(this.target.x, this.target.y, Math.max(6, this.cellSize / 4), 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
// 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();
// draw preview: rectangle or brush circle
if (this.isDrawing && this.drawStart && this.drawEnd && this.tool === 'rect') {
ctx.save();
ctx.strokeStyle = this.drawModeAdd ? 'rgba(200,0,0,0.9)' : 'rgba(0,160,0,0.9)';
ctx.lineWidth = 2;
ctx.setLineDash([6,4]);
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();
}
if (this.isDrawing && this.drawEnd && (this.tool === 'brush' || this.tool === 'erase')) {
// draw brush preview circle
const center = this.drawEnd;
const radiusPx = (this.brushSize / 2) * this.cellSize;
ctx.save();
ctx.strokeStyle = this.tool === 'erase' ? 'rgba(0,160,0,0.9)' : 'rgba(200,0,0,0.9)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(center.x, center.y, radiusPx, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
},
5.1清空画布
javascript
const canvas = this.$refs.canvas;
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.getContext('2d') 拿到 2D 绘图环境
clearRect 把整个画布擦掉,避免叠加之前的痕迹
每次 redraw 都会从空白开始画
5.2 绘制永久障碍物(黑色)
javascript
ctx.save();
ctx.fillStyle = '#111';
for (const key of this.permanentObstacles) {
const [r, c] = key.split(',').map(Number);
ctx.fillRect(c * this.cellSize, r * this.cellSize, this.cellSize, this.cellSize);
}
ctx.restore();
临时障碍(用户绘制或修改的)
ctx.save() / ctx.restore() 保存当前绘图状态(颜色、线条宽度、透明度等),防止修改后影响其他绘制。
globalAlpha 设置半透明
fillRect 画格子(根据行列位置 r,c 转换成像素坐标)
遍历所有永久障碍物:
key 是字符串 "行,列",比如 "2,3"
split(',').map(Number) 把字符串变成 [2,3] 数字数组
fillRect(c * this.cellSize, r * this.cellSize, this.cellSize,
this.cellSize)
c * this.cellSize → 横坐标
r * this.cellSize → 纵坐标
this.cellSize → 格子的宽和高
就是在对应格子位置画一个黑色方块
5.3绘制网格线(可选)
javascript
ctx.save();
ctx.strokeStyle = 'rgba(0,0,0,0.06)';
for (let c = 0; c <= this.cols; c++) {
const x = c * this.cellSize;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, this.height); ctx.stroke();
}
for (let r = 0; r <= this.rows; r++) {
const y = r * this.cellSize;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(this.width, y); ctx.stroke();
}
ctx.restore();
画网格线方便看格子
线很淡 rgba(0,0,0,0.06)
用两次循环分别画竖线和横线
5.4绘制路径(蓝线)
javascript
if (this.path && this.path.length) {
ctx.save();
ctx.strokeStyle = '#1976d2';
ctx.lineWidth = Math.max(2, this.cellSize / 8);
ctx.beginPath();
ctx.moveTo(this.path[0].x, this.path[0].y);
for (let i = 1; i < this.path.length; i++) ctx.lineTo(this.path[i].x, this.path[i].y);
ctx.stroke();
ctx.restore();
}
this.path 是 A* 算出的路径
按像素坐标绘制蓝色折线
lineWidth 根据格子大小自动适配
strokeStyle → 设置线条颜色为蓝色
lineWidth → 设置线宽(根据格子大小自适应,最小 2px)
beginPath() → 开始一条新路径
moveTo → 把笔移动到路径起点(不画)
lineTo → 依次连到每个路径点,形成折线
stroke() → 真正把路径画出来
restore() → 恢复绘图状态
5.5绘制目标点(红色圆)
javascript
if (this.target) {
ctx.save();
ctx.fillStyle = 'red';
ctx.beginPath();
ctx.arc(this.target.x, this.target.y, Math.max(6, this.cellSize / 4), 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
this.target 是角色要去的点
画一个红色小圆标记
半径根据格子大小自适应
5.6绘制绘制工具的预览(矩形 / 笔刷)
矩形工具
javascript
if (this.isDrawing && this.drawStart && this.drawEnd && this.tool === 'rect') {
//条件判断:
//正在绘制 (isDrawing)
//已有起点和终点
//当前工具是矩形 (rect)
//满足才画预览矩形
ctx.save();
ctx.strokeStyle = this.drawModeAdd ? 'rgba(200,0,0,0.9)' : 'rgba(0,160,0,0.9)';
ctx.lineWidth = 2;
ctx.setLineDash([6,4]);
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)
// 根据鼠标起点和终点画一个矩形轮廓:
//Math.min → 左上角坐标
//Math.abs → 宽高(保证正数)
//只是画预览,不改变网格数据
);
ctx.restore();
}
当鼠标拖动时显示预览矩形
红色表示加障碍,绿色表示擦除
虚线用 setLineDash 实现
笔刷 / 橡皮
javascript
if (this.isDrawing && this.drawEnd && (this.tool === 'brush' || this.tool === 'erase')) {
const center = this.drawEnd;
const radiusPx = (this.brushSize / 2) * this.cellSize;
ctx.save();
ctx.strokeStyle = this.tool === 'erase' ? 'rgba(0,160,0,0.9)' : 'rgba(200,0,0,0.9)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(center.x, center.y, radiusPx, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
鼠标位置显示圆形预览
红色画笔、绿色橡皮
半径根据 brushSize 自适应
总结 redraw()
- 清空画布
- 画临时障碍 → 永久障碍
- 画网格线
- 画路径
- 画目标点
- 画角色
六 上传背景图
javascript
<label style="margin-left:8px;">
上传背景图:
<input type="file" accept="image/*" @change="onImageFileChange" />
</label>
onImageFileChange(e) {
const file = e.target.files && e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
this.bgSrc = ev.target.result;
};
reader.readAsDataURL(file);
},
input → 表示一个输入框。
type="file" → 这是"文件选择框",用户可以从电脑里选文件。
accept="image/*" → 限制只能选择图片(jpg、png、gif 等),不能选文档或视频。
@change="onImageFileChange" → 当用户选了图片后,会触发 Vue 方法
onImageFileChange,你可以在这个方法里拿到用户选的图片做处理。
e.target.result 是读取到的文件内容(通常是 base64 字符串或者 blob URL)。
把它赋值给 this.bgImageUrl,Vue 会响应式更新页面 告诉 FileReader 以 Data URL
的形式读取文件,也就是把图片内容转成 base64 字符串,这样可以直接用作 img 的 src。
七 点击画布后根据工具不同做出对应操作
javascript
onCanvasClick(e) {
// 1. 获取鼠标点击在画布上的坐标(像素值)
const pos = this._getMousePos(e);
// 2. Ctrl/Cmd + 点击,用于切换临时障碍(仅在工具为 click 时生效)
if ((e.ctrlKey || e.metaKey) && this.tool === 'click') {
const cell = this.pixelToCell(pos.x, pos.y); // 将像素坐标转换为格子坐标
const key = `${cell.r},${cell.c}`; // 格子坐标转为字符串 key
if (this.permanentObstacles.has(key)) return; // 如果是永久障碍,不能切换
if (this.obstacles.has(key)) {
// 已经是临时障碍 → 删除障碍,并设为可走
this.obstacles.delete(key);
this.grid[cell.r][cell.c] = true;
} else {
// 不是障碍 → 添加临时障碍,并设为不可走
this.obstacles.add(key);
this.grid[cell.r][cell.c] = false;
}
// 清空路径和目标,停止动画,刷新画布
this.path = [];
this.target = null;
this.stopAnimation();
this.redraw();
return; // 结束点击处理
}
// 3. 工具为 brush / erase 时,单击直接绘制或擦除
if (this.tool === 'brush') {
this.paintAtPixel(pos.x, pos.y, true); // 添加障碍
return;
}
if (this.tool === 'erase') {
this.paintAtPixel(pos.x, pos.y, false); // 删除障碍
return;
}
// 4. 默认行为:点击画布设置移动目标并寻路
const endCell = this.pixelToCell(pos.x, pos.y); // 点击点转换为格子
if (!this.grid[endCell.r][endCell.c]) { // 点击点不可走
const near = this.findNearestWalkable(pos.x, pos.y); // 找最近可走的格子
if (!near) { return; } // 找不到可走格子,结束
this.target = near; // 设置目标为最近可走点
} else {
this.target = this.cellCenter(endCell.r, endCell.c); // 点击点可走,直接设置为目标
}
// 5. 检查 agent 当前所在格子是否可走,如果不可走则移动到最近可走点
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) return;
this.agent.x = near.x;
this.agent.y = near.y;
}
// 6. 使用 A* 算法寻找路径
const cellPath = this.findPathAStar(
startCell,
this.pixelToCell(this.target.x, this.target.y)
);
// 7. 如果找不到路径,清空路径、目标,停止动画并刷新画布
if (!cellPath || cellPath.length === 0) {
this.path = [];
this.target = null;
this.stopAnimation();
this.redraw();
return;
}
// 8. 将格子路径转换为像素坐标(中心点),用于动画移动
this.path = cellPath.map(n => this.cellCenter(n.r, n.c));
// 9. 启动动画,让 agent 按路径移动
this.startAnimation();
}
7.1获取鼠标点击位置
javascript
const pos = this._getMousePos(e);
_getMousePos 会把鼠标事件的坐标转换成画布上的像素坐标 {x, y}。
方便后续把像素位置转换成格子位置。
javascript
_getMousePos(e) {
const rect = this.$refs.canvas.getBoundingClientRect();
const scaleX = this.$refs.canvas.width / rect.width;
const scaleY = this.$refs.canvas.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
return { x, y };
},
this.$refs.canvas:指向模板中 的 DOM 元素。
getBoundingClientRect() 返回元素相对于视口的位置和尺寸信息:{ left, top, right, bottom,
width, height }。 canvas 的 内部绘图尺寸 和 CSS 显示尺寸 可能不一样,例如 。
scaleX 和 scaleY 用来计算屏幕坐标到 canvas 内坐标的缩放比例。 e.clientX / e.clientY:鼠标事件在
视口(浏览器窗口) 的坐标。
e.clientX - rect.left:鼠标相对于 canvas 左上角的偏移。
再乘以 scaleX / scaleY,得到 canvas 内部坐标,用于在 canvas 上绘图或逻辑计算。
7.2Brush / Erase 工具单击
javascript
if (this.tool === 'brush') { this.paintAtPixel(pos.x, pos.y, true); return; }
if (this.tool === 'erase') { this.paintAtPixel(pos.x, pos.y, false); return; }
作用:单击时用笔刷画障碍或用橡皮擦去障碍。
paintAtPixel 会把对应像素转换成格子,并更新障碍状态。
javascript
paintAtPixel(x, y, add) {
const cell = this.pixelToCell(x, y);
const centerR = cell.r, centerC = cell.c;
const radiusCells = Math.floor(this.brushSize / 2);
const rmin = Math.max(0, centerR - radiusCells), rmax = Math.min(this.rows - 1, centerR + radiusCells);
const cmin = Math.max(0, centerC - radiusCells), cmax = Math.min(this.cols - 1, centerC + radiusCells);
for (let r = rmin; r <= rmax; r++) {
for (let c = cmin; c <= cmax; c++) {
const dr = r - centerR, dc = c - centerC;
if (dr*dr + dc*dc > radiusCells*radiusCells) continue;
const key = `${r},${c}`;
if (add) {
this.permanentObstacles.add(key);
this.grid[r][c] = false;
this.obstacles.add(key);
} else {
// **删除行为:同时删除 permanent 与 temp,并设为可走**
if (this.permanentObstacles.has(key)) this.permanentObstacles.delete(key);
if (this.obstacles.has(key)) this.obstacles.delete(key);
this.grid[r][c] = true;
}
}
}
this.redraw();
},
paintAtPixel(x, y, add) 的作用是:在画布上某个像素点周围"画"或者"擦除"障碍。x, y:鼠标点击的像素坐标add:布尔值,true 表示画障碍,false 表示删除障碍
核心步骤拆解
像素转网格
javascript
const cell = this.pixelToCell(x, y);
const centerR = cell.r, centerC = cell.c;
先把像素坐标转换成对应的网格行列 (r, c)
这个点就是"圆心",从这里开始画圆形笔刷
计算圆形笔刷范围
javascript
const radiusCells = Math.floor(this.brushSize / 2);
const rmin = Math.max(0, centerR - radiusCells), rmax = Math.min(this.rows - 1, centerR + radiusCells);
const cmin = Math.max(0, centerC - radiusCells), cmax = Math.min(this.cols - 1, centerC + radiusCells);
radiusCells:笔刷半径(单位是格子数)
rmin/rmax 和 cmin/cmax:限定循环范围,防止越界
遍历圆形范围
javascript
for (let r = rmin; r <= rmax; r++) {
for (let c = cmin; c <= cmax; c++) {
const dr = r - centerR, dc = c - centerC;
if (dr*dr + dc*dc > radiusCells*radiusCells) continue;
遍历矩形区域里的每个格子
用勾股定理判断 (r, c) 是否在圆形笔刷范围内
不在圆里的格子直接跳过
画障碍
javascript
const key = `${r},${c}`;
if (add) {
this.permanentObstacles.add(key);
this.grid[r][c] = false;
this.obstacles.add(key);
key:格子唯一标识 "r,c"
添加障碍:
permanentObstacles:永久障碍集合
obstacles:当前障碍集合(可能包括临时障碍)
grid[r][c] = false:设置这个格子不可走
删除障碍
javascript
} else {
if (this.permanentObstacles.has(key)) this.permanentObstacles.delete(key);
if (this.obstacles.has(key)) this.obstacles.delete(key);
this.grid[r][c] = true;
}
删除障碍时:
同时删除永久和临时障碍集合里的格子
并把网格标记为可走
重绘画布
javascript
this.redraw();
画完或删完障碍后刷新画布,让视觉上立刻更新
javascript
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)) };
},
假设用户点击位置(像素坐标)是:
x = 400 px, y = 200 px
onCanvasClick / onMouseDown 中第一个发生的是:把像素坐标转成网格格子
调用 _getMousePos(e) 得到画布内部像素(假设 canvas scale = 1,这里直接用点击像素),随后 paintAtPixel 内部调用:
pixelToCell 做的是:
c = Math.floor(x / this.cellSize) = Math.floor(400 / 8) = 50
r = Math.floor(y / this.cellSize) = Math.floor(200 / 8) = 25
所以中心格子是 (r=25, c=50)。
7.2点击格子处理(目标点)
javascript
const endCell = this.pixelToCell(pos.x, pos.y);
if (!this.grid[endCell.r][endCell.c]) {
const near = this.findNearestWalkable(pos.x, pos.y);
if (!near) { return; }
this.target = near;
} else {
this.target = this.cellCenter(endCell.r, endCell.c);
}
作用:确定点击位置的最终目标点 this.target
如果点击到障碍 → 找最近可走点
点击到可走格 → 直接用格子中心
7.3起点格子处理(角色当前位置)
javascript
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) return;
this.agent.x = near.x; this.agent.y = near.y;
}
this.agent 是当前移动的角色或物体
startCell 是角色所在的格子
如果角色当前位置在障碍格子:
找最近可走格 near
把角色移动到该可走格
保证 起点一定在可走格,否则 A* 会找不到路径
7.4寻路
javascript
const cellPath = this.findPathAStar(startCell, this.pixelToCell(this.target.x, this.target.y));
调用 A* 寻路算法 findPathAStar
从 startCell → target 的格子坐标
返回值 cellPath 是格子序列,例如:[{r:0,c:0},{r:0,c:1},...]
如果返回 null 或空数组 → 表示没有可走路径
7.5
javascript
if (!cellPath || cellPath.length === 0) {
this.path = [];
this.target = null;
this.stopAnimation();
this.redraw();
return;
}
如果没有路径:
清空路径和目标
停止动画
重绘画布
避免角色卡住或画布状态异常
7.5将格子路径转成像素路径
javascript
this.path = cellPath.map(n => this.cellCenter(n.r, n.c));
cellCenter(r,c) 把格子坐标转换成 像素坐标
this.path 就是角色实际要走的像素坐标序列
这一步是让动画可以平滑移动,而不是按格子跳
7.6 this.startAnimation();
根据 this.path 开始角色移动
通常是逐帧或 requestAnimationFrame 循环执行
角色会沿着计算好的路径走向目标
7.7BFS 最近点搜索算法
javascript
findNearestWalkable(px, py) {
// 1. 将像素坐标转换成格子坐标 {r, c}
const start = this.pixelToCell(px, py);
// 2. 如果起始格子本身就是可走的,直接返回这个格子的中心像素坐标
if (this.grid[start.r][start.c]) return this.cellCenter(start.r, start.c);
// 3. 初始化 BFS 队列,把起点格子放入队列
const q = [start];
// 4. 初始化已访问集合,防止重复搜索
const seen = new Set([`${start.r},${start.c}`]);
// 5. 定义八个方向(上下左右 + 四个斜角)
const dirs = [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[-1,1],[1,-1],[1,1]];
// 6. BFS 循环,只要队列不为空就继续搜索
while (q.length) {
// 7. 取队列头部的当前格子
const cur = q.shift();
// 8. 遍历当前格子的八个邻居
for (const d of dirs) {
// 9. 计算邻居的行列坐标
const nr = cur.r + d[0], nc = cur.c + d[1];
// 10. 如果邻居越界则跳过
if (!this._inBounds(nr, nc)) continue;
// 11. 用字符串 key 表示格子坐标
const k = `${nr},${nc}`;
// 12. 如果邻居格子已经访问过则跳过
if (seen.has(k)) continue;
// 13. 标记邻居格子为已访问
seen.add(k);
// 14. 如果邻居格子可走,返回它的中心像素坐标(BFS 保证最近)
if (this.grid[nr][nc]) return this.cellCenter(nr, nc);
// 15. 如果邻居格子不可走,加入队列,继续搜索
q.push({ r: nr, c: nc });
}
}
// 16. 队列为空仍未找到可走格子,返回 null
return null;
},
行 1-2:先检查自己是否可走
行 3-5:准备 BFS 队列和方向
行 6-15:广度优先搜索最近可走格子
行 16:找不到则返回 null
7.8A* 寻路算法
javascript
findPathAStar(start, end) {
// 1. 如果起点或终点越界,直接返回 null
if (!this._inBounds(start.r, start.c) || !this._inBounds(end.r, end.c)) return null;
// 2. 如果起点或终点不可走(被障碍物挡住),直接返回 null
if (!this.grid[start.r][start.c] || !this.grid[end.r][end.c]) return null;
// 3. 辅助函数,把格子坐标转换成字符串 key
const key = (p) => `${p.r},${p.c}`;
// 4. g 存储起点到当前节点的实际代价
const g = new Map();
// 5. f 存储估计总代价 g + h(A* 核心)
const f = new Map();
// 6. came 存储路径上每个节点的父节点,用于回溯路径
const came = new Map();
// 7. open 列表,存储待处理的节点
const open = [];
// 8. 启发函数 h,计算两个格子的估计代价(使用八方向距离)
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; // 横纵移动代价为 1,对角线代价为 √2
return D * (dx + dy) + (D2 - 2 * D) * Math.min(dx, dy);
};
// 9. 将起点和终点转换为 key
const sKey = key(start), eKey = key(end);
// 10. 起点 g 值为 0,f 值为 g + h
g.set(sKey, 0);
f.set(sKey, h(start, end));
// 11. 将起点加入 open 列表
open.push({ r: start.r, c: start.c, f: f.get(sKey) });
// 12. closed 集合,用于记录已经处理过的节点
const closed = new Set();
// 13. 八个方向移动
const dirs = [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[-1,1],[1,-1],[1,1]];
// 14. 主循环,只要 open 列表不为空就继续处理
while (open.length) {
// 15. 按 f 值从小到大排序,取估价最低的节点优先处理
open.sort((a,b) => a.f - b.f);
// 16. 取 open 列表的第一个节点作为当前节点
const cur = open.shift();
const curKey = key(cur);
// 17. 如果当前节点已处理,跳过
if (closed.has(curKey)) continue;
// 18. 如果到达终点,回溯路径并返回
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;
}
// 19. 将当前节点加入 closed 集合
closed.add(curKey);
// 20. 遍历当前节点的八个邻居
for (const d of dirs) {
const nr = cur.r + d[0], nc = cur.c + d[1];
// 21. 如果邻居越界,跳过
if (!this._inBounds(nr, nc)) continue;
const nbKey = `${nr},${nc}`;
// 22. 如果邻居不可走或是永久障碍,跳过
if (!this.grid[nr][nc] || this.permanentObstacles.has(nbKey)) continue;
// 23. 对角移动额外检查:不能斜穿障碍
if (Math.abs(d[0]) + Math.abs(d[1]) === 2) {
// 对角的两个相邻节点必须都可走
if (!this.grid[cur.r + d[0]][cur.c] || !this.grid[cur.r][cur.c + d[1]]) continue;
const k1 = `${cur.r + d[0]},${cur.c}`;
const k2 = `${cur.r},${cur.c + d[1]}`;
if (this.permanentObstacles.has(k1) || this.permanentObstacles.has(k2)) continue;
}
// 24. 如果邻居已经在 closed 集合中,跳过
if (closed.has(nbKey)) continue;
// 25. 计算从起点到邻居的代价 tentativeG
const tentativeG = g.get(curKey) + ((nr === cur.r || nc === cur.c) ? 1 : Math.SQRT2);
// 26. 如果这个邻居是新节点或者找到更小的 g 值
if (g.get(nbKey) === undefined || tentativeG < g.get(nbKey)) {
// 27. 更新邻居的父节点为当前节点
came.set(nbKey, curKey);
// 28. 更新邻居的 g 值
g.set(nbKey, tentativeG);
// 29. 计算邻居 f 值 = g + h
const ff = tentativeG + h({ r: nr, c: nc }, end);
f.set(nbKey, ff);
// 30. 将邻居加入 open 列表
open.push({ r: nr, c: nc, f: ff });
}
}
}
// 31. open 列表为空,未找到路径,返回 null
return null;
}
起点检查:确保起点和终点可走。
BFS+A*:open 列表维护待搜索节点,closed 集合避免重复处理。
f=g+h:g 是已走的代价,h 是估算剩余代价。
对角线处理:防止斜穿障碍。
路径回溯:用 came Map 从终点回溯到起点生成路径。
总结
这个函数就是 笔刷操作的核心逻辑
核心思想:
像素坐标 → 网格坐标
计算圆形笔刷范围
遍历范围,把格子标记为可走/不可走,并同步障碍集合
重绘画布
注意:
add=true → 画障碍
add=false → 删除障碍
确定目标格子(点击点或最近可走点)确保起点格子可走
A 寻路得到格子序列*
格子序列转换为像素序列
触发动画让角色移动
8.不想看前面直接点击这里(直接复制完整代码)
javascript
<template>
<div class="pf-demo">
<div class="controls">
<label
>cellSize:
<input type="number" v-model.number="cellSize" min="2" max="64" />
</label>
<label style="margin-left: 8px">
上传背景图:
<input type="file" accept="image/*" @change="onImageFileChange" />
</label>
<!-- 工具选择与笔刷粗细 -->
<label style="margin-left: 8px">
工具:
<select v-model="tool">
<option value="brush">画笔</option>
<option value="erase">橡皮</option>
<option value="rect">矩形</option>
<option value="click">点击(寻路/临时障切换)</option>
</select>
</label>
<label v-if="tool === 'brush' || tool === 'erase'" style="margin-left: 8px">
笔刷大小:
<input type="range" min="1" max="31" step="2" v-model.number="brushSize" />
<span>{{ brushSize }}</span>
</label>
<!-- 矩形模式下切换添加/删除 -->
<button
v-if="tool === 'rect'"
@click="toggleDrawMode"
:title="'当前模式:' + (drawModeAdd ? '添加' : '删除')"
>
模式: {{ drawModeAdd ? "添加" : "删除" }}
</button>
<button @click="randomObstacles">随机障碍(临时)</button>
<button @click="clearObstacles">清空临时障碍(保留永久)</button>
<button @click="stopAnimation">停止</button>
<button @click="exportPermanentObstacles">导出永久障碍</button>
<button @click="copyExportText">复制导出文本</button>
<span class="hint">
说明:画笔/橡皮支持拖动绘制;矩形工具为拖拽矩形;点击工具用于寻路与 Ctrl
切换临时障碍。
</span>
</div>
<div
class="canvas-wrap"
:style="{
width: displayWidth + 'px',
height: displayHeight + 'px',
position: 'relative',
}"
>
<img
ref="bgImg"
:src="bgSrc"
v-if="bgSrc"
alt="background"
:style="{
position: 'absolute',
top: 0,
left: 0,
width: displayWidth + 'px',
height: displayHeight + 'px',
userSelect: 'none',
pointerEvents: 'none',
}"
@load="onBgImageLoad"
/>
<div
v-else
class="placeholder"
:style="{
width: displayWidth + 'px',
height: displayHeight + 'px',
position: 'absolute',
top: 0,
left: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999',
}"
>
请上传背景图片
</div>
<canvas
ref="canvas"
:style="{ position: 'absolute', top: 0, left: 0 }"
@click="onCanvasClick"
@dblclick="onCanvasDblClick"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
></canvas>
</div>
<div class="info">
网格:{{ cols }} × {{ rows }} (cellSize={{ cellSize }}) | 路径点:{{
path.length
}}
| 动画中:{{ animating }} | 永久障碍:{{ permanentObstacles.size }}
</div>
<div style="margin-top: 8px">
<textarea
v-model="exportText"
readonly
rows="6"
style="width: 100%; font-family: monospace"
></textarea>
</div>
</div>
</template>
<script>
export default {
name: "ImageObstacleEditor",
data() {
return {
// image & canvas display
bgSrc: "",
imgNaturalWidth: 900,
imgNaturalHeight: 520,
displayWidth: 900,
displayHeight: 520,
// canvas/grid config
width: 900,
height: 520,
cellSize: 8,
cols: 0,
rows: 0,
grid: null,
// obstacles
obstacles: new Set(), // 临时
permanentObstacles: new Set([
"56,141",
"57,141",
"58,141",
"59,141",
"60,141",
"61,141",
"62,141",
"63,141",
"64,141",
"65,141",
"66,141",
"67,141",
"68,141",
"69,140",
"69,141",
"70,90",
"70,91",
"70,92",
"70,93",
"70,94",
"70,95",
"70,96",
"70,97",
"70,98",
"70,99",
"70,100",
"70,101",
"70,102",
"70,103",
"70,104",
"70,105",
"70,106",
"70,107",
"70,108",
"70,109",
"70,110",
"70,111",
"70,112",
"70,113",
"70,114",
"70,115",
"70,116",
"70,117",
"70,118",
"70,119",
"70,120",
"70,121",
"70,122",
"70,123",
"70,124",
"70,125",
"70,126",
"70,127",
"70,128",
"70,129",
"70,130",
"70,131",
"70,132",
"70,133",
"70,134",
"70,135",
"70,136",
"70,137",
"70,138",
"70,139",
"70,140",
"70,141",
"75,83",
"75,84",
"75,85",
"75,86",
"75,87",
"75,88",
"75,89",
"75,90",
"75,93",
"75,94",
"75,95",
"75,96",
"75,97",
"75,98",
"75,99",
"75,100",
"75,101",
"75,102",
"75,103",
"75,104",
"75,105",
"75,106",
"75,107",
"75,108",
"75,109",
"75,110",
"75,111",
"75,112",
"75,114",
"75,115",
"75,116",
"75,117",
"75,118",
"75,119",
"75,120",
"75,121",
"75,122",
"75,123",
"75,124",
"75,125",
"75,126",
"75,127",
"75,130",
"75,131",
"75,132",
"75,133",
"75,134",
"75,135",
"75,136",
"75,137",
"75,138",
"75,141",
"76,83",
"76,91",
"76,92",
"76,93",
"76,94",
"76,95",
"76,111",
"76,117",
"76,118",
"76,119",
"76,120",
"76,121",
"76,122",
"76,123",
"76,124",
"76,127",
"76,141",
"77,83",
"77,111",
"77,127",
"77,141",
"78,83",
"78,111",
"78,127",
"78,141",
"79,83",
"79,110",
"79,111",
"79,127",
"79,141",
"80,83",
"80,110",
"80,111",
"80,127",
"80,141",
"81,83",
"81,110",
"81,111",
"81,127",
"81,141",
"82,83",
"82,110",
"82,111",
"82,115",
"82,116",
"82,127",
"82,141",
"83,83",
"83,111",
"83,116",
"83,127",
"83,141",
"84,83",
"84,111",
"84,116",
"84,127",
"84,141",
"85,83",
"85,111",
"85,116",
"85,127",
"86,83",
"86,111",
"86,115",
"86,127",
"86,141",
"87,83",
"87,111",
"87,115",
"87,127",
"87,141",
"88,83",
"88,111",
"88,115",
"88,127",
"88,141",
"89,83",
"89,111",
"89,115",
"89,127",
"89,141",
"90,83",
"90,115",
"90,127",
"90,141",
"91,83",
"91,115",
"91,127",
"91,141",
"92,83",
"92,87",
"92,88",
"92,89",
"92,90",
"92,91",
"92,92",
"92,93",
"92,94",
"92,95",
"92,96",
"92,97",
"92,98",
"92,99",
"92,100",
"92,101",
"92,102",
"92,103",
"92,104",
"92,105",
"92,106",
"92,107",
"92,108",
"92,109",
"92,110",
"92,111",
"92,112",
"92,114",
"92,115",
"92,116",
"92,117",
"92,118",
"92,119",
"92,120",
"92,121",
"92,122",
"92,123",
"92,124",
"92,126",
"92,127",
"92,128",
"92,129",
"92,130",
"92,131",
"92,132",
"92,133",
"92,134",
"92,135",
"92,136",
"92,137",
"92,138",
"92,140",
"92,141",
"93,124",
"93,125",
"93,126",
"93,138",
"93,139",
"93,140",
]), // 永久
// agent & path
agent: { x: 120, y: 260, speed: 220 },
path: [],
target: null,
animating: false,
// animation internals
_rafId: null,
_lastTime: 0,
_moveIndex: 0,
// mouse drawing state
isDrawing: false,
drawStart: null,
drawEnd: null,
drawModeAdd: true, // 矩形模式下 添加/删除
// tool & brush
tool: "brush", // 'brush' | 'erase' | 'rect' | 'click'
brushSize: 5, // odd number (格子为单位) 推荐奇数便于中心对称
// export
exportText: "",
};
},
mounted() {
this.width = this.displayWidth;
this.height = this.displayHeight;
const cvs = this.$refs.canvas;
cvs.width = this.width;
cvs.height = this.height;
cvs.style.width = this.width + "px";
cvs.style.height = this.height + "px";
this.initGrid();
this.redraw();
//无论谁调用你,你里面的 this 都指向当前组件/对象
this._animateStep = this._animateStep.bind(this);
},
beforeDestroy() {
this.stopAnimation();
},
methods: {
// ---------- image ----------
onImageFileChange(e) {
const file = e.target.files && e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
this.bgSrc = ev.target.result;
};
reader.readAsDataURL(file);
},
onBgImageLoad() {
const img = this.$refs.bgImg;
this.imgNaturalWidth = img.naturalWidth || img.width || this.displayWidth;
this.imgNaturalHeight = img.naturalHeight || img.height || this.displayHeight;
this.displayWidth = this.imgNaturalWidth;
this.displayHeight = this.imgNaturalHeight;
const cvs = this.$refs.canvas;
this.width = this.displayWidth;
this.height = this.displayHeight;
cvs.width = this.width;
cvs.height = this.height;
cvs.style.width = this.width + "px";
cvs.style.height = this.height + "px";
this.initGrid();
this.redraw();
},
// ---------- grid ----------
initGrid() {
this.cols = Math.ceil(this.width / this.cellSize);
this.rows = Math.ceil(this.height / 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);
for (const key of this.permanentObstacles) {
const [r, c] = key.split(",").map(Number);
if (this._inBounds(r, c)) this.grid[r][c] = false;
}
// 临时障碍物
for (const key of this.obstacles) {
const [r, c] = key.split(",").map(Number);
const k = `${r},${c}`;
if (this._inBounds(r, c) && !this.permanentObstacles.has(k))
this.grid[r][c] = false;
}
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.agent.x = near.x;
this.agent.y = near.y;
} else {
const c = this.cellCenter(0, 0);
this.agent.x = c.x;
this.agent.y = c.y;
}
}
},
// ---------- obstacle ops ----------
randomObstacles() {
this.initGrid();
const count = Math.floor(this.rows * this.cols * 0.12);
for (let i = 0; i < count; i++) {
const r = Math.floor(Math.random() * this.rows);
const c = Math.floor(Math.random() * this.cols);
const key = `${r},${c}`;
const ac = this.pixelToCell(this.agent.x, this.agent.y);
if (r === ac.r && c === ac.c) continue;
if (this.permanentObstacles.has(key)) continue;
this.grid[r][c] = false;
this.obstacles.add(key);
}
this.path = [];
this.target = null;
this.stopAnimation();
this.redraw();
},
clearObstacles() {
if (!this.grid) return;
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
const k = `${r},${c}`;
if (!this.permanentObstacles.has(k)) this.grid[r][c] = true;
else this.grid[r][c] = false;
}
}
this.obstacles.clear();
for (const key of this.permanentObstacles) this.obstacles.add(key);
this.path = [];
this.target = null;
this.stopAnimation();
this.redraw();
},
toggleDrawMode() {
this.drawModeAdd = !this.drawModeAdd;
},
// ---------- mouse handlers ----------
onCanvasClick(e) {
const pos = this._getMousePos(e);
// Ctrl/Cmd + click: 切换临时障碍(仅在 click 工具下)
if ((e.ctrlKey || e.metaKey) && this.tool === "click") {
const cell = this.pixelToCell(pos.x, pos.y);
const key = `${cell.r},${cell.c}`;
if (this.permanentObstacles.has(key)) return;
if (this.obstacles.has(key)) {
this.obstacles.delete(key);
this.grid[cell.r][cell.c] = true;
} else {
this.obstacles.add(key);
this.grid[cell.r][cell.c] = false;
}
this.path = [];
this.target = null;
this.stopAnimation();
this.redraw();
return;
}
// 工具行为:brush / erase 单击时生效
if (this.tool === "brush") {
this.paintAtPixel(pos.x, pos.y, true);
return;
}
if (this.tool === "erase") {
this.paintAtPixel(pos.x, pos.y, false);
return;
}
// 否则按原逻辑:点击设置目标并寻路
const endCell = this.pixelToCell(pos.x, pos.y);
if (!this.grid[endCell.r][endCell.c]) {
const near = this.findNearestWalkable(pos.x, pos.y);
if (!near) {
return;
}
this.target = near;
} else {
this.target = this.cellCenter(endCell.r, endCell.c);
}
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) return;
this.agent.x = near.x;
this.agent.y = near.y;
}
const cellPath = this.findPathAStar(
startCell,
this.pixelToCell(this.target.x, this.target.y)
);
if (!cellPath || cellPath.length === 0) {
this.path = [];
this.target = null;
this.stopAnimation();
this.redraw();
return;
}
this.path = cellPath.map((n) => this.cellCenter(n.r, n.c));
this.startAnimation();
},
onCanvasDblClick() {
this.target = null;
this.path = [];
this.stopAnimation();
this.redraw();
},
onMouseDown(e) {
const pos = this._getMousePos(e);
// 矩形:开始记录矩形起点
if (this.tool === "rect") {
this.isDrawing = true;
this.drawStart = pos;
this.drawEnd = null;
return;
}
// 画笔 / 橡皮:开始绘制并立即应用第一个点
if (this.tool === "brush") {
this.isDrawing = true;
this.drawModeAdd = true;
this.drawEnd = pos;
this.paintAtPixel(pos.x, pos.y, true);
return;
}
if (this.tool === "erase") {
this.isDrawing = true;
this.drawModeAdd = false;
this.drawEnd = pos;
this.paintAtPixel(pos.x, pos.y, false);
return;
}
},
onMouseMove(e) {
if (!this.isDrawing) return;
const pos = this._getMousePos(e);
this.drawEnd = pos;
if (this.tool === "brush") this.paintAtPixel(pos.x, pos.y, true);
else if (this.tool === "erase") this.paintAtPixel(pos.x, pos.y, false);
else this.redraw(); // rect preview
},
onMouseUp(e) {
if (!this.isDrawing) return;
this.isDrawing = false;
const pos = this._getMousePos(e);
this.drawEnd = pos;
if (this.tool === "rect") {
const x1 = Math.min(this.drawStart.x, this.drawEnd.x),
x2 = Math.max(this.drawStart.x, this.drawEnd.x);
const y1 = Math.min(this.drawStart.y, this.drawEnd.y),
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 (!this._inBounds(r, c)) continue;
const key = `${r},${c}`;
if (this.drawModeAdd) {
this.permanentObstacles.add(key);
this.grid[r][c] = false;
this.obstacles.add(key);
} else {
// **删除:同时删除 permanent 与 temp**
if (this.permanentObstacles.has(key)) this.permanentObstacles.delete(key);
if (this.obstacles.has(key)) this.obstacles.delete(key);
this.grid[r][c] = true;
}
}
}
this.redraw();
}
this.drawStart = null;
this.drawEnd = null;
this.path = [];
this.target = null;
this.stopAnimation();
this.redraw();
},
// ---------- paint helper (已修改:erase 会删除永久与临时障) ----------
/**
* x,y 为像素坐标;add=true 添加;add=false 删除(同时删除 permanent 与 temp)
*/
paintAtPixel(x, y, add) {
const cell = this.pixelToCell(x, y);
const centerR = cell.r,
centerC = cell.c;
const radiusCells = Math.floor(this.brushSize / 2);
const rmin = Math.max(0, centerR - radiusCells),
rmax = Math.min(this.rows - 1, centerR + radiusCells);
const cmin = Math.max(0, centerC - radiusCells),
cmax = Math.min(this.cols - 1, centerC + radiusCells);
for (let r = rmin; r <= rmax; r++) {
for (let c = cmin; c <= cmax; c++) {
const dr = r - centerR,
dc = c - centerC;
if (dr * dr + dc * dc > radiusCells * radiusCells) continue;
const key = `${r},${c}`;
if (add) {
this.permanentObstacles.add(key);
this.grid[r][c] = false;
this.obstacles.add(key);
} else {
// **删除行为:同时删除 permanent 与 temp,并设为可走**
if (this.permanentObstacles.has(key)) this.permanentObstacles.delete(key);
if (this.obstacles.has(key)) this.obstacles.delete(key);
this.grid[r][c] = true;
}
}
}
this.redraw();
},
// ---------- utilities ----------
_getMousePos(e) {
const rect = this.$refs.canvas.getBoundingClientRect();
const scaleX = this.$refs.canvas.width / rect.width;
const scaleY = this.$refs.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) {
const start = this.pixelToCell(px, py);
if (this.grid[start.r][start.c]) return this.cellCenter(start.r, start.c);
const q = [start];
const seen = new Set([`${start.r},${start.c}`]);
const dirs = [
[-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 dirs) {
const nr = cur.r + d[0],
nc = cur.c + d[1];
if (!this._inBounds(nr, nc)) continue;
const k = `${nr},${nc}`;
if (seen.has(k)) continue;
seen.add(k);
if (this.grid[nr][nc]) return this.cellCenter(nr, nc);
q.push({ r: nr, c: nc });
}
}
return null;
},
// ---------- A* ----------
findPathAStar(start, end) {
if (!this._inBounds(start.r, start.c) || !this._inBounds(end.r, end.c)) return null;
if (!this.grid[start.r][start.c] || !this.grid[end.r][end.c]) return null;
const key = (p) => `${p.r},${p.c}`;
const g = new Map(),
f = new Map(),
came = new Map();
const open = [];
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),
eKey = key(end);
g.set(sKey, 0);
f.set(sKey, h(start, end));
open.push({ r: start.r, c: start.c, f: f.get(sKey) });
const closed = new Set();
const dirs = [
[-1, 0],
[1, 0],
[0, -1],
[0, 1],
[-1, -1],
[-1, 1],
[1, -1],
[1, 1],
];
while (open.length) {
open.sort((a, b) => a.f - b.f);
const cur = open.shift();
const curKey = key(cur);
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);
for (const d of dirs) {
const nr = cur.r + d[0],
nc = cur.c + d[1];
if (!this._inBounds(nr, nc)) continue;
const nbKey = `${nr},${nc}`;
if (!this.grid[nr][nc] || this.permanentObstacles.has(nbKey)) continue;
if (Math.abs(d[0]) + Math.abs(d[1]) === 2) {
if (!this.grid[cur.r + d[0]][cur.c] || !this.grid[cur.r][cur.c + d[1]])
continue;
const k1 = `${cur.r + d[0]},${cur.c}`;
const k2 = `${cur.r},${cur.c + d[1]}`;
if (this.permanentObstacles.has(k1) || this.permanentObstacles.has(k2))
continue;
}
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);
const ff = tentativeG + h({ r: nr, c: nc }, end);
f.set(nbKey, ff);
open.push({ r: nr, c: nc, f: ff });
}
}
}
return null;
},
_inBounds(r, c) {
return r >= 0 && r < this.rows && c >= 0 && c < this.cols;
},
// ---------- animation ----------
startAnimation() {
if (!this.path || this.path.length < 2) return;
if (Math.hypot(this.path[0].x - this.agent.x, this.path[0].y - this.agent.y) > 1) {
this.path.unshift({ x: this.agent.x, y: this.agent.y });
}
this._moveIndex = 1;
this.animating = true;
this._lastTime = performance.now();
if (this._rafId) cancelAnimationFrame(this._rafId);
this._rafId = requestAnimationFrame(this._animateStep);
},
_animateStep(ts) {
if (!this.animating) return;
const dt = (ts - this._lastTime) / 1000;
this._lastTime = ts;
if (!this.path || this._moveIndex >= this.path.length) {
this.animating = false;
this.redraw();
return;
}
const target = this.path[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.agent.x = target.x;
this.agent.y = target.y;
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;
}
}
this.redraw();
this._rafId = requestAnimationFrame(this._animateStep);
},
stopAnimation() {
this.animating = false;
if (this._rafId) {
cancelAnimationFrame(this._rafId);
this._rafId = null;
}
},
// ---------- drawing ----------
/**
* 重新绘制画布,显示网格、障碍物、路径和角色
*
* 绘制内容包括:
* - 临时障碍物(红色,半透明)
* - 永久障碍物(黑色)
* - 网格线(浅灰色)
* - 路径(蓝色线条)
* - 目标点(红色圆圈)
* - 智能体(橙色圆圈)
* - 绘制预览(矩形或画笔圆圈)
*/
redraw() {
const canvas = this.$refs.canvas;
if (!canvas) return;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
// draw temporary obstacles (red)
ctx.save();
ctx.globalAlpha = 0.9;
ctx.fillStyle = "#b33";
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 permanent obstacles (black)
ctx.save();
ctx.fillStyle = "#111";
for (const key of this.permanentObstacles) {
const [r, c] = key.split(",").map(Number);
ctx.fillRect(c * this.cellSize, r * this.cellSize, this.cellSize, this.cellSize);
}
ctx.restore();
// optional: grid lines
ctx.save();
ctx.strokeStyle = "rgba(0,0,0,0.06)";
for (let c = 0; c <= this.cols; c++) {
const x = c * this.cellSize;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, this.height);
ctx.stroke();
}
for (let r = 0; r <= this.rows; r++) {
const y = r * this.cellSize;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(this.width, y);
ctx.stroke();
}
ctx.restore();
// path
if (this.path && this.path.length) {
ctx.save();
ctx.strokeStyle = "#1976d2";
ctx.lineWidth = Math.max(2, this.cellSize / 8);
ctx.beginPath();
ctx.moveTo(this.path[0].x, this.path[0].y);
for (let i = 1; i < this.path.length; i++)
ctx.lineTo(this.path[i].x, this.path[i].y);
ctx.stroke();
ctx.restore();
}
// target
if (this.target) {
ctx.save();
ctx.fillStyle = "red";
ctx.beginPath();
ctx.arc(
this.target.x,
this.target.y,
Math.max(6, this.cellSize / 4),
0,
Math.PI * 2
);
ctx.fill();
ctx.restore();
}
// 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();
// draw preview: rectangle or brush circle
if (this.isDrawing && this.drawStart && this.drawEnd && this.tool === "rect") {
ctx.save();
ctx.strokeStyle = this.drawModeAdd ? "rgba(200,0,0,0.9)" : "rgba(0,160,0,0.9)";
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
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();
}
if (
this.isDrawing &&
this.drawEnd &&
(this.tool === "brush" || this.tool === "erase")
) {
// draw brush preview circle
const center = this.drawEnd;
const radiusPx = (this.brushSize / 2) * this.cellSize;
ctx.save();
ctx.strokeStyle =
this.tool === "erase" ? "rgba(0,160,0,0.9)" : "rgba(200,0,0,0.9)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(center.x, center.y, radiusPx, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
},
// ---------- export ----------
exportPermanentObstacles() {
// 将 Set -> 有序数组(按 r,c 排序,方便比对)
const arrRC = Array.from(this.permanentObstacles)
.map((k) => {
const [r, c] = k.split(",").map(Number);
return { r, c, key: `${r},${c}` };
})
.sort((a, b) => a.r - b.r || a.c - b.c);
// 1) Set 格式(可直接粘到 data() 的返回对象中)
const setContent =
"permanentObstacles: new Set([" +
arrRC.map((o) => `'${o.key}'`).join(", ") +
"])";
const setSnippet = "// 直接粘到组件 data() 返回对象中:\n" + setContent + "\n";
// 2) 紧凑行列数组(方便后端或保存)
const compactArr = "[" + arrRC.map((o) => `[${o.r},${o.c}]`).join(", ") + "]";
const compactSnippet =
"// 紧凑格式([[r,c],[r,c],...]),适合后端或存库:\n" +
"const permanentCells = " +
compactArr +
";\n";
// 3) 像素矩形格式(左上角像素 + 宽高)
const rects = arrRC.map((o) => {
const x = o.c * this.cellSize;
const y = o.r * this.cellSize;
return `{ x: ${x}, y: ${y}, w: ${this.cellSize}, h: ${this.cellSize} }`;
});
const rectSnippet =
"// 像素矩形(左上角像素 + 宽高):\n" +
"const permanentRects = [" +
rects.join(", ") +
"];\n";
// 最终拼接到 exportText,用户可直接复制
this.exportText = setSnippet + "\n" + compactSnippet + "\n" + rectSnippet;
// 尝试复制到剪贴板(优先复制 Set 格式)
const toCopy = setContent; // 若你想复制更紧凑格式,把 compactArr 或 rects 改这里
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
navigator.clipboard
.writeText(toCopy)
.then(() => {
// 成功复制(可选提示)
console.log("已复制可直接粘贴到 data() 的 Set 文本到剪贴板");
})
.catch(() => {
// 失败则不抛错误,页面上用户仍可手动复制 exportText
console.warn("复制到剪贴板失败,请手动复制文本框内容");
});
} else {
// 兼容性退路:选中文本区域让用户手动复制
this.$nextTick(() => {
const ta = this.$el.querySelector("textarea");
if (ta) {
ta.select();
}
});
}
// 同时在 console 输出紧凑 JSON 以备查
console.log("permanent Set text:", setContent);
console.log("compact array:", compactArr);
},
copyExportText() {
if (!this.exportText) return;
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
navigator.clipboard.writeText(this.exportText).catch(() => {
const ta = this.$el.querySelector("textarea");
if (ta) ta.select();
});
} else {
const ta = this.$el.querySelector("textarea");
if (ta) ta.select();
}
},
// ---------- programmatic helpers ----------
addPermanentRectByPixels(x, y, w, h) {
const c1 = Math.floor(x / this.cellSize);
const c2 = Math.floor((x + w) / this.cellSize);
const r1 = Math.floor(y / this.cellSize);
const r2 = Math.floor((y + h) / this.cellSize);
for (let r = r1; r <= r2; r++) {
for (let c = c1; c <= c2; c++) {
if (!this._inBounds(r, c)) continue;
const key = `${r},${c}`;
this.permanentObstacles.add(key);
this.grid[r][c] = false;
this.obstacles.add(key);
}
}
this.redraw();
},
addPermanentAtPixel(x, y) {
const cell = this.pixelToCell(x, y);
if (!this._inBounds(cell.r, cell.c)) return;
const key = `${cell.r},${cell.c}`;
this.permanentObstacles.add(key);
this.grid[cell.r][cell.c] = false;
this.obstacles.add(key);
this.redraw();
},
},
};
</script>
<style scoped>
.pf-demo {
font-family: Arial, Helvetica, sans-serif;
max-width: 1200px;
}
.controls {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 10px;
flex-wrap: wrap;
}
.canvas-wrap {
border: 1px solid #ddd;
display: inline-block;
background: #f8f8f8;
}
.placeholder {
background: #fafafa;
border: 1px dashed #ddd;
}
canvas {
display: block;
cursor: crosshair;
}
.info {
margin-top: 8px;
color: #333;
}
.hint {
color: #666;
font-size: 0.9em;
margin-left: 10px;
}
button {
padding: 6px 10px;
cursor: pointer;
}
textarea {
box-sizing: border-box;
}
select {
padding: 4px;
}
input[type="range"] {
vertical-align: middle;
}
</style>