import type { MapPen, Rect } from '@api/map'; import type { RobotGroup, RobotInfo, RobotLabel, RobotType } from '@api/robot'; import type { SceneData } from '@api/scene'; import sTheme from '@core/theme.service'; import { CanvasLayer, LockState, type Meta2dStore, s8 } from '@meta2d/core'; import { useObservable } from '@vueuse/rxjs'; import { clone, get, isNil, nth, remove, some } from 'lodash-es'; import { BehaviorSubject, debounceTime } from 'rxjs'; import { reactive } from 'vue'; import cargoIcon from '../../assets/icons/png/cargo.png'; import chargingIcon from '../../assets/icons/png/charging.png'; import notAcceptingOrdersIcon from '../../assets/icons/png/notAcceptingOrders.png'; import colorConfig from '../color/color-config.service'; interface RobotStatusPosition { x: number; y: number; rotate: number; } export interface EditorRobotContext { store: Meta2dStore; data(): any; setValue(pen: Partial & { id: string }, options: { render: boolean; history: boolean; doEvent: boolean }): void; updatePen(id: string, pen: Partial, record?: boolean, render?: boolean): void; addPen(pen: MapPen, history?: boolean, storeData?: boolean, render?: boolean): Promise; top(pens: MapPen[]): void; render(): void; getPenById(id?: string): MapPen | undefined; getPenRect(pen: MapPen): Rect; find(target: string): MapPen[]; inactive(): void; } export class EditorRobotService { private readonly robotMap = reactive>(new Map()); private readonly robotGroups$$ = new BehaviorSubject([]); private readonly robotLabels$$ = new BehaviorSubject([]); public readonly robotGroups = useObservable(this.robotGroups$$.pipe(debounceTime(300))); public readonly robotLabels = useObservable(this.robotLabels$$.pipe(debounceTime(300))); constructor(private readonly ctx: EditorRobotContext) {} //#region 初始化与基础数据 public loadInitialData(groups?: RobotGroup[], robots?: RobotInfo[], labels?: RobotLabel[]): void { this.robotMap.clear(); robots?.forEach((v) => this.robotMap.set(v.id, v)); this.robotGroups$$.next(groups ?? []); this.robotLabels$$.next(labels ?? []); this.syncRobots(); this.syncRobotGroups(); this.syncRobotLabels(); } public get robots(): RobotInfo[] { return Array.from(this.robotMap.values()); } public hasRobot(id: RobotInfo['id']): boolean { return this.robotMap.has(id); } public getRobotById(id: RobotInfo['id']): RobotInfo | undefined { return this.robotMap.get(id); } public updateRobot(id: RobotInfo['id'], value: Partial): void { const robot = this.getRobotById(id); if (isNil(robot)) return; this.robotMap.set(id, { ...robot, ...value }); if (value.label) { this.ctx.setValue({ id, text: value.label }, { render: false, history: false, doEvent: false }); } this.syncRobots(); } public addRobots(gid: RobotInfo['gid'], robots: RobotInfo[]): void { const groups = clone(this.robotGroups$$.value); const group = groups.find((v) => v.id === gid); if (isNil(group)) throw Error('未找到目标机器人组'); group.robots ??= []; robots.forEach((robot) => { if (this.robotMap.has(robot.id)) return; this.robotMap.set(robot.id, { ...robot, gid }); group.robots?.push(robot.id); }); this.robotGroups$$.next(groups); this.syncRobots(); this.syncRobotGroups(); } public removeRobots(ids: RobotInfo['id'][]): void { ids?.forEach((id) => this.robotMap.delete(id)); const groups = clone(this.robotGroups$$.value); groups.forEach(({ robots }) => remove(robots ?? [], (id) => !this.robotMap.has(id))); this.robotGroups$$.next(groups); const labels = clone(this.robotLabels$$.value); labels.forEach(({ robots }) => remove(robots ?? [], (id) => !this.robotMap.has(id))); this.robotLabels$$.next(labels); this.syncRobots(); this.syncRobotGroups(); this.syncRobotLabels(); } public updateRobots(ids: RobotInfo['id'][], value: Partial): void { ids?.forEach((id) => { const robot = this.robotMap.get(id); if (isNil(robot)) return; this.robotMap.set(id, { ...robot, ...value }); }); this.syncRobots(); } public createRobotGroup(): void { const id = s8(); const label = `RG${id}`; const groups = clone(this.robotGroups$$.value); groups.push({ id, label }); this.robotGroups$$.next(groups); this.syncRobotGroups(); } public deleteRobotGroup(id: RobotGroup['id']): void { const groups = clone(this.robotGroups$$.value); const group = groups.find((v) => v.id === id); group?.robots?.forEach((rid) => this.robotMap.delete(rid)); remove(groups, group); this.robotGroups$$.next(groups); this.syncRobots(); this.syncRobotGroups(); } public updateRobotGroupLabel(id: RobotGroup['id'], label: RobotGroup['label']): void { const groups = this.robotGroups$$.value; const group = groups.find((v) => v.id === id); if (isNil(group)) throw Error('未找到目标机器人组'); if (some(groups, ['label', label])) throw Error('机器人组名称已存在'); group.label = label; this.robotGroups$$.next([...groups]); this.syncRobotGroups(); } public createRobotLabel(): void { const id = s8(); const label = `RL${id}`; const labels = clone(this.robotLabels$$.value); labels.push({ id, label }); this.robotLabels$$.next(labels); this.syncRobotLabels(); } public deleteRobotLabel(id: RobotLabel['id']): void { const labels = clone(this.robotLabels$$.value); const labelToDelete = labels.find((v) => v.id === id); if (!labelToDelete) return; const robotsInLabel = labelToDelete.robots; if (robotsInLabel) { robotsInLabel.forEach((robotId) => { const robot = this.getRobotById(robotId); if (robot?.lid) { const newLid = robot.lid.filter((lid) => lid !== id); this.updateRobot(robot.id, { lid: newLid }); } }); } remove(labels, (l) => l.id === id); this.robotLabels$$.next(labels); this.syncRobotLabels(); } public updateRobotLabel(id: RobotLabel['id'], labelName: RobotLabel['label']): void { const labels = this.robotLabels$$.value; const label = labels.find((v) => v.id === id); if (isNil(label)) throw Error('未找到目标标签'); if (some(labels, ['label', labelName])) throw Error('机器人标签名称已存在'); label.label = labelName; this.robotLabels$$.next([...labels]); this.syncRobotLabels(); } public addRobotsToLabel(lid: RobotLabel['id'], robots: RobotInfo[]): void { const labels = clone(this.robotLabels$$.value); const label = labels.find((v) => v.id === lid); if (isNil(label)) throw Error('未找到目标标签'); label.robots ??= []; robots.forEach((robot) => { if (this.robotMap.has(robot.id)) { const existingRobot = this.robotMap.get(robot.id)!; existingRobot.lid ??= []; if (!existingRobot.lid.includes(lid)) { existingRobot.lid.push(lid); } if (!label.robots!.includes(robot.id)) { label.robots!.push(robot.id); } } else { const newRobot = { ...robot, lid: [lid] }; this.robotMap.set(robot.id, newRobot); label.robots?.push(robot.id); } }); this.robotLabels$$.next(labels); this.syncRobots(); this.syncRobotLabels(); } public removeRobotFromLabel(labelId: string, robotId: string): void { const labels = clone(this.robotLabels$$.value); const label = labels.find((v) => v.id === labelId); if (label?.robots) { remove(label.robots, (id) => id === robotId); } this.robotLabels$$.next(labels); const robot = this.getRobotById(robotId); if (robot?.lid) { const newLid = robot.lid.filter((id) => id !== labelId); this.updateRobot(robotId, { lid: newLid }); } this.syncRobotLabels(); this.syncRobots(); } public removeRobotsFromAllLabels(robotIds: string[]): void { robotIds.forEach((robotId) => { const robot = this.getRobotById(robotId); if (robot) { this.updateRobot(robotId, { lid: [] }); } }); const labels = clone(this.robotLabels$$.value); labels.forEach((label) => { if (label.robots) { remove(label.robots, (id) => robotIds.includes(id)); } }); this.robotLabels$$.next(labels); this.syncRobots(); this.syncRobotLabels(); } //#endregion //#region 画布绘制 public async initRobots(): Promise { await Promise.all( this.robots.map(async ({ id, label, type }) => { const pen: MapPen = { ...this.mapRobotImage(type, true, label), id, name: 'robot', tags: ['robot'], x: 0, y: 0, width: 120, height: 120, lineWidth: 1, robot: { type }, visible: false, text: label, textTop: -18, whiteSpace: 'nowrap', ellipsis: false, locked: LockState.Disable, }; console.log(`[InitRobots] Creating robot ${id} with visible: false`); await this.ctx.addPen(pen, false, true, true); this.updateRobotStatusOverlay(id, false); }), ); } public updateAllRobotImageSizes(): void { this.robots.forEach(({ id, type, label }) => { const pen = this.ctx.getPenById(id); if (pen && pen.robot) { const imageConfig = this.mapRobotImage(type, pen.robot.active, label); this.ctx.setValue( { id, iconWidth: imageConfig.iconWidth, iconHeight: imageConfig.iconHeight, iconTop: imageConfig.iconTop, }, { render: true, history: false, doEvent: false }, ); this.updateRobotStatusOverlay(id, true); } }); } public updateRobotImage(robotName: string): void { const robot = this.robots.find((r) => r.label === robotName); if (!robot) return; const pen = this.ctx.getPenById(robot.id); if (pen && pen.robot) { const imageConfig = this.mapRobotImage(robot.type, pen.robot.active, robotName); this.ctx.setValue( { id: robot.id, image: imageConfig.image, iconWidth: imageConfig.iconWidth, iconHeight: imageConfig.iconHeight, iconTop: imageConfig.iconTop, }, { render: true, history: false, doEvent: false }, ); } } public updateRobotStatusOverlay(id: string, render = false, newPosition?: RobotStatusPosition): void { const pen = this.ctx.getPenById(id); if (!pen) return; // [修复] 只有在机器人有有效位置数据时才设置为可见,避免在初始位置闪烁 const hasValidPosition = (pen.x !== undefined && pen.y !== undefined && (pen.x !== 0 || pen.y !== 0)) || (newPosition?.x !== undefined && newPosition?.y !== undefined && (newPosition.x !== 0 || newPosition.y !== 0)); // 只有在机器人有有效位置且当前不可见时,才设置为可见 if (hasValidPosition && pen.visible === false) { this.ctx.setValue({ id, visible: true }, { render: false, history: false, doEvent: false }); } const icon = this.getRobotStatusIcon(pen); // 机器人可见性取决于是否有有效位置数据 const robotVisible = hasValidPosition; const baseW = (pen as any).iconWidth ?? 42; const baseH = (pen as any).iconHeight ?? 76; const oW = Math.max(8, Math.floor(baseW * 0.5)); const oH = Math.max(8, Math.floor(baseH * 0.5)); const rect = this.ctx.getPenRect(pen); const deg: number = newPosition?.rotate ?? pen.rotate ?? 0; const theta = (deg * Math.PI) / 180; const cx = (newPosition?.x ?? rect.x) + rect.width / 2; const cy = (newPosition?.y ?? rect.y) + rect.height / 2; const iconTop = (pen as any).iconTop ?? 0; const localDx = 0; const localDy = iconTop + 16; const rotDx = localDx * Math.cos(theta) - localDy * Math.sin(theta); const rotDy = localDx * Math.sin(theta) + localDy * Math.cos(theta); const icx = cx + rotDx; const icy = cy + rotDy; const ox = icx - oW / 2; const oy = icy - oH / 2; const overlayId = `robot-status-${id}`; let exist = this.ctx.getPenById(overlayId); // 如果机器人不可见(理论上经过上方修复后不会发生),则隐藏覆盖物 if (!robotVisible) { if (exist) { this.ctx.setValue({ id: overlayId, visible: false }, { render, history: false, doEvent: false }); } return; } // 如果覆盖层不存在但机器人可见,则创建 if (!exist) { const overlayPen: MapPen = { id: overlayId, name: 'image', tags: ['robot-status'], x: ox, y: oy, width: oW, height: oH, image: icon, rotate: deg, canvasLayer: CanvasLayer.CanvasImage, locked: LockState.Disable, visible: true, } as any; this.ctx.addPen(overlayPen, false, false, true); // 重新获取刚创建的覆盖层 exist = this.ctx.getPenById(overlayId); if (exist) { this.ctx.top([exist]); } } else { // 更新现有覆盖层 this.ctx.setValue( { id: overlayId, image: icon, x: ox, y: oy, width: oW, height: oH, rotate: deg, visible: icon ? true : false, // 如果没有图标,则隐藏覆盖层 locked: LockState.Disable, }, { render: false, history: false, doEvent: false }, ); if (icon) { this.ctx.top([exist]); // 只有在有图标时才置顶 } } if (render) this.ctx.render(); } public mapRobotImage(type: RobotType, active?: boolean, robotName?: string): Required> { const theme = this.ctx.data().theme; const useCustomImages = colorConfig.getColor('robot.useCustomImages') === 'true'; let image: string; if (useCustomImages && robotName) { const customImage = colorConfig.getRobotCustomImage(robotName, active ? 'active' : 'normal'); if (customImage) { image = customImage; } else { image = import.meta.env.BASE_URL + (active ? `/robot/${type}-active-${theme}.png` : `/robot/${type}-${theme}.png`); } } else { image = import.meta.env.BASE_URL + (active ? `/robot/${type}-active-${theme}.png` : `/robot/${type}-${theme}.png`); } const iconWidth = colorConfig.getColor('robot.imageWidth') ? Number(colorConfig.getColor('robot.imageWidth')) : 42; const iconHeight = colorConfig.getColor('robot.imageHeight') ? Number(colorConfig.getColor('robot.imageHeight')) : 76; const iconTop = this.calculatePixelAlignedOffset(-16); return { image, iconWidth, iconHeight, iconTop, canvasLayer: CanvasLayer.CanvasImage }; } //#endregion private getRobotStatusIcon(pen?: MapPen): string | null { if (!pen) return null; const r1: any = pen.robot ?? {}; const r2 = this.getRobotById(pen.id || ''); const toBool = (v: any) => v === true || v === 1 || v === '1'; const isLoading = toBool(r1?.isLoading ?? r2?.isLoading); const canOrder = toBool(r1?.canOrder ?? r2?.canOrder); const isCharging = toBool(r1?.isCharging ?? r2?.isCharging); if (isLoading) return cargoIcon; if (!canOrder) return notAcceptingOrdersIcon; if (isCharging) return chargingIcon; return null; } private calculatePixelAlignedOffset(baseOffset: number): number { const ratio = window.devicePixelRatio || 1; const scaled = baseOffset * ratio; const snapped = Math.round(scaled); const logicalOffset = snapped / ratio; return this.fixPrecision(logicalOffset); } private fixPrecision(value: number): number { const truncated = Math.floor(value * 1000) / 1000; return parseFloat(truncated.toFixed(3)); } private syncRobots(): void { (this.ctx.store.data as SceneData).robots = [...this.robotMap.values()]; } private syncRobotGroups(): void { (this.ctx.store.data as SceneData).robotGroups = this.robotGroups$$.value; } private syncRobotLabels(): void { (this.ctx.store.data as SceneData).robotLabels = this.robotLabels$$.value; } } function getRobotStatus(isWaring?: boolean, isFault?: boolean): 'fault' | 'warning' | 'normal' { if (isFault) return 'fault'; if (isWaring) return 'warning'; return 'normal'; } export function drawRobot(ctx: CanvasRenderingContext2D, pen: MapPen): void { const theme = sTheme.editor; const { lineWidth: s = 1 } = pen.calculative ?? {}; const { x = 0, y = 0, width: w = 0, height: h = 0, rotate: deg = 0 } = pen.calculative?.worldRect ?? {}; const { active, path, isWaring, isFault } = pen.robot ?? {}; if (!active) return; const status = getRobotStatus(isWaring, isFault); const imageWidth = colorConfig.getColor('robot.imageWidth') ? Number(colorConfig.getColor('robot.imageWidth')) : 60; const imageHeight = colorConfig.getColor('robot.imageHeight') ? Number(colorConfig.getColor('robot.imageHeight')) : 80; const imageSize = Math.max(imageWidth, imageHeight); const robotRadius = (imageSize * 1.2 * s) / 2; const ox = x + w / 2; const oy = y + h / 2; ctx.save(); ctx.beginPath(); ctx.arc(ox, oy, robotRadius, 0, Math.PI * 2); ctx.fillStyle = colorConfig.getColor(`robot.fill${status === 'normal' ? 'Normal' : status === 'warning' ? 'Warning' : 'Fault'}`) || (get(theme, `robot.fill-${status}`) ?? get(theme, 'robot.fill') ?? ''); ctx.fill(); ctx.strokeStyle = colorConfig.getColor(`robot.stroke${status === 'normal' ? 'Normal' : status === 'warning' ? 'Warning' : 'Fault'}`) || (get(theme, `robot.stroke-${status}`) ?? get(theme, 'robot.stroke') ?? ''); ctx.stroke(); if (path?.length) { ctx.strokeStyle = colorConfig.getColor('robot.line') || (get(theme, 'robot.line') ?? ''); ctx.lineCap = 'round'; ctx.lineWidth = s * 4; ctx.setLineDash([s * 5, s * 10]); ctx.translate(ox, oy); ctx.rotate((-deg * Math.PI) / 180); ctx.beginPath(); ctx.moveTo(0, 0); path.forEach((d) => ctx.lineTo(d.x * s, d.y * s)); ctx.stroke(); const { x: ex1 = 0, y: ey1 = 0 } = nth(path, -1) ?? {}; const { x: ex2 = 0, y: ey2 = 0 } = nth(path, -2) ?? {}; const r = Math.atan2(ey1 - ey2, ex1 - ex2) + Math.PI; ctx.setLineDash([0]); ctx.translate(ex1 * s, ey1 * s); ctx.beginPath(); ctx.moveTo(Math.cos(r + Math.PI / 4) * s * 10, Math.sin(r + Math.PI / 4) * s * 10); ctx.lineTo(0, 0); ctx.lineTo(Math.cos(r - Math.PI / 4) * s * 10, Math.sin(r - Math.PI / 4) * s * 10); ctx.stroke(); ctx.setTransform(1, 0, 0, 1, 0, 0); } ctx.restore(); }