# 路径漫游功能开发 ## 概述 > **目标**: 实现路径漫游功能,支持单条路径的漫游点管理和播放 > > **交付物**: > - 漫游点管理(添加、删除、跳转) > - 路径设置(漫游时间、循环播放) > - 漫游播放(播放时高亮当前点) > > **预估工作量**: 中等 > **执行方式**: 顺序执行 --- ## 原型交互图 ``` ┌─────────────────────────────────────┐ │ 路径漫游 [×] │ ├─────────────────────────────────────┤ │ │ │ ─────────── 路径设置 ─────────── │ │ │ │ 漫游时间 │ │ ┌───────────────────────┐ │ │ │ 10 │ 秒 │ │ └───────────────────────┘ │ │ │ │ ☑ 循环播放 │ │ │ │ ─────────── 漫游点 ──────────── │ │ │ │ ┌────────────┐ ┌────────────┐ │ │ │ +添加漫游点│ │ 删除全部 │ │ │ └────────────┘ └────────────┘ │ │ │ │ ┌─────────────────────────────┐ │ │ │ 漫游点0 [▶][×]│ │ ← 播放时高亮 │ └─────────────────────────────┘ │ │ ┌─────────────────────────────┐ │ │ │ 漫游点1 [▶][×]│ │ │ └─────────────────────────────┘ │ │ ┌─────────────────────────────┐ │ │ │ 漫游点2 [▶][×]│ │ │ └─────────────────────────────┘ │ │ │ │ ┌─────────────────────────────┐ │ │ │ ▶ 播放漫游 │ │ │ └─────────────────────────────┘ │ │ │ └─────────────────────────────────────┘ ``` **关键交互**: - 打开弹窗时:调用 `getPoints()` 获取已有漫游点 - 添加漫游点:调用 `addPoint()` 添加当前位置 - 删除漫游点:调用 `removePoint(index)` - 跳转到点:调用 `jumpToPoint(index)` - 播放漫游:调用 `play(options)`,通过 `onPointComplete` 高亮当前点 --- ## 调用链路 ``` 用户操作 (WalkPathPanel) ↓ registry.engine3d.pathRoamingXxx() ↓ EngineManager (managers/engine-manager.ts) ↓ this.engineInstance.pathRoamingXxx() ↓ Engine (components/engine/index.ts) ↓ this.engine.pathRoaming.xxx() ↓ 底层引擎 SDK ``` --- ## 任务列表 ### 任务 1: 添加国际化文本 **修改文件**: - `src/locales/types.ts` - `src/locales/zh-CN.ts` - `src/locales/en-US.ts` **types.ts 修改内容** (替换 `walkControl.path` 部分): ```typescript path: { /** 对话框标题 */ dialogTitle: string; /** 漫游时间标签 */ duration: string; /** 时间单位 */ durationUnit: string; /** 循环播放 */ loop: string; /** 添加漫游点按钮 */ addPoint: string; /** 删除全部按钮 */ deleteAll: string; /** 漫游点前缀 */ point: string; /** 播放按钮 */ play: string; /** 无漫游点提示 */ noPoints: string; }; ``` **zh-CN.ts 修改内容**: ```typescript path: { dialogTitle: '路径漫游', duration: '漫游时间', durationUnit: '秒', loop: '循环播放', addPoint: '添加漫游点', deleteAll: '删除全部', point: '漫游点', play: '播放漫游', noPoints: '暂无漫游点,请添加' } ``` **en-US.ts 修改内容**: ```typescript path: { dialogTitle: 'Path Roaming', duration: 'Duration', durationUnit: 's', loop: 'Loop', addPoint: 'Add Point', deleteAll: 'Delete All', point: 'Point', play: 'Play', noPoints: 'No points yet' } ``` **验收标准**: - [x] 三个文件都已修改 - [x] 构建无错误 --- ### 任务 2: 创建类型定义 **新建文件**: `src/components/walk-path-panel/types.ts` ```typescript /** * 漫游点接口 * 表示路径中的一个漫游点 */ export interface RoamingPoint { /** 漫游点索引 */ index: number; } /** * 播放选项接口 * 配置漫游播放的参数 */ export interface PlayOptions { /** 总播放时长(毫秒),不包括停留时间 */ duration?: number; /** 是否循环播放 */ loop?: boolean; /** 播放完成的回调 */ onComplete?: () => void; /** 每个点播放完成的回调,用于高亮当前点 */ onPointComplete?: (pointIndex: number) => void; } ``` **验收标准**: - [x] 文件已创建 - [x] 包含所有接口定义和注释 --- ### 任务 3: Engine 添加 pathRoaming 方法 **修改文件**: `src/components/engine/index.ts` **添加位置**: 在 `getEngineInfo()` 方法后面添加以下代码 ```typescript // ==================== 路径漫游 ==================== /** * 添加漫游点 * 将当前相机位置添加为一个漫游点 */ public pathRoamingAddPoint(): void { // 检查引擎是否已初始化 if (!this._isInitialized || !this.engine?.pathRoaming) { console.warn('[Engine] pathRoaming not available'); return; } // 调用底层 API 添加点位 this.engine.pathRoaming.addPoint(0); } /** * 删除指定索引的漫游点 * @param index 要删除的漫游点索引 */ public pathRoamingRemovePoint(index: number): void { if (!this._isInitialized || !this.engine?.pathRoaming) { console.warn('[Engine] pathRoaming not available'); return; } this.engine.pathRoaming.removePoint(index); } /** * 清除所有漫游点 */ public pathRoamingClearPoints(): void { if (!this._isInitialized || !this.engine?.pathRoaming) { console.warn('[Engine] pathRoaming not available'); return; } this.engine.pathRoaming.clearPoints(); } /** * 获取所有漫游点 * @returns 漫游点数组 */ public pathRoamingGetPoints(): any[] { if (!this._isInitialized || !this.engine?.pathRoaming) { console.warn('[Engine] pathRoaming not available'); return []; } return this.engine.pathRoaming.getPoints() ?? []; } /** * 跳转到指定漫游点 * @param index 目标漫游点索引 */ public pathRoamingJumpToPoint(index: number): void { if (!this._isInitialized || !this.engine?.pathRoaming) { console.warn('[Engine] pathRoaming not available'); return; } this.engine.pathRoaming.jumpToPoint(index); } /** * 播放漫游 * @param options 播放选项,包含时长、循环、回调等配置 */ public pathRoamingPlay(options?: { duration?: number; loop?: boolean; onComplete?: () => void; onPointComplete?: (pointIndex: number) => void; }): void { if (!this._isInitialized || !this.engine?.pathRoaming) { console.warn('[Engine] pathRoaming not available'); return; } this.engine.pathRoaming.play(options); } // ==================== 结束:路径漫游 ==================== ``` **验收标准**: - [x] 6个方法已添加 - [x] 每个方法都有 JSDoc 注释 - [x] 构建无错误 --- ### 任务 4: EngineManager 添加代理方法 **修改文件**: `src/managers/engine-manager.ts` **添加位置**: 在 `getEngineInfo()` 方法后面添加以下代码 ```typescript // ==================== 路径漫游 ==================== /** * 添加漫游点 * 将当前相机位置添加为一个漫游点 */ public pathRoamingAddPoint(): void { this.engineInstance?.pathRoamingAddPoint(); } /** * 删除指定索引的漫游点 * @param index 要删除的漫游点索引 */ public pathRoamingRemovePoint(index: number): void { this.engineInstance?.pathRoamingRemovePoint(index); } /** * 清除所有漫游点 */ public pathRoamingClearPoints(): void { this.engineInstance?.pathRoamingClearPoints(); } /** * 获取所有漫游点 * @returns 漫游点数组 */ public pathRoamingGetPoints(): any[] { return this.engineInstance?.pathRoamingGetPoints() ?? []; } /** * 跳转到指定漫游点 * @param index 目标漫游点索引 */ public pathRoamingJumpToPoint(index: number): void { this.engineInstance?.pathRoamingJumpToPoint(index); } /** * 播放漫游 * @param options 播放选项,包含时长、循环、回调等配置 */ public pathRoamingPlay(options?: { duration?: number; loop?: boolean; onComplete?: () => void; onPointComplete?: (pointIndex: number) => void; }): void { this.engineInstance?.pathRoamingPlay(options); } // ==================== 结束:路径漫游 ==================== ``` **验收标准**: - [x] 6个代理方法已添加 - [x] 每个方法都有 JSDoc 注释 - [x] 构建无错误 --- ### 任务 5: 实现 WalkPathPanel 组件 **修改文件**: `src/components/walk-path-panel/index.ts` **完整替换为以下代码**: ```typescript import './index.css'; import type { ThemeConfig } from '../../themes/types'; import { IBimComponent } from '../../types/component'; import { localeManager, t } from '../../services/locale'; import { themeManager } from '../../services/theme'; import { ManagerRegistry } from '../../core/manager-registry'; import type { RoamingPoint } from './types'; /** * 路径漫游面板组件 * 提供漫游点的添加、删除、跳转和播放功能 */ export class WalkPathPanel implements IBimComponent { public element!: HTMLElement; /** 管理器注册表实例 */ private registry = ManagerRegistry.getInstance(); /** 国际化订阅取消函数 */ private unsubscribeLocale: (() => void) | null = null; /** 主题订阅取消函数 */ private unsubscribeTheme: (() => void) | null = null; /** 漫游点列表 */ private points: RoamingPoint[] = []; /** 漫游时间(毫秒) */ private duration: number = 10000; /** 是否循环播放 */ private loop: boolean = false; /** 当前播放中的点索引,-1 表示未播放 */ private playingPointIndex: number = -1; /** 是否正在播放 */ private isPlaying: boolean = false; /** * 初始化组件 * 创建 DOM 元素,订阅国际化和主题变化,加载已有漫游点 */ public init(): void { // 创建根元素 this.element = document.createElement('div'); this.element.className = 'walk-path-panel'; // 订阅国际化变化 this.unsubscribeLocale = localeManager.subscribe(() => this.render()); // 订阅主题变化 this.unsubscribeTheme = themeManager.subscribe((theme) => this.setTheme(theme)); // 从引擎加载已有的漫游点 this.loadPointsFromEngine(); // 渲染界面 this.render(); // 应用当前主题 this.setTheme(themeManager.getTheme()); } /** * 从引擎加载已有的漫游点 * 在面板打开时调用,获取底层引擎中已存在的漫游点 */ private loadPointsFromEngine(): void { const enginePoints = this.registry.engine3d?.pathRoamingGetPoints() ?? []; // 将引擎返回的点转换为本地格式 this.points = enginePoints.map((_: any, index: number) => ({ index: index })); } /** * 渲染整个面板 * 清空现有内容并重新构建界面 */ private render(): void { this.element.innerHTML = ''; // 渲染路径设置区域 const settings = this.createSettingsSection(); this.element.appendChild(settings); // 渲染漫游点区域 const pointsSection = this.createPointsSection(); this.element.appendChild(pointsSection); // 渲染播放按钮 const playBtn = this.createPlayButton(); this.element.appendChild(playBtn); } /** * 创建路径设置区域 * 包含漫游时间和循环播放设置 */ private createSettingsSection(): HTMLElement { const section = document.createElement('div'); section.className = 'walk-path-settings'; // ===== 漫游时间 ===== const durationGroup = document.createElement('div'); durationGroup.className = 'walk-path-form-group'; const durationLabel = document.createElement('label'); durationLabel.textContent = t('walkControl.path.duration'); const durationWrapper = document.createElement('div'); durationWrapper.className = 'walk-path-input-wrapper'; const durationInput = document.createElement('input'); durationInput.type = 'number'; durationInput.className = 'walk-path-input'; durationInput.value = String(this.duration / 1000); durationInput.min = '1'; durationInput.oninput = (e) => { // 更新漫游时间,转换为毫秒 const val = parseInt((e.target as HTMLInputElement).value) || 1; this.duration = val * 1000; }; const durationUnit = document.createElement('span'); durationUnit.className = 'walk-path-unit'; durationUnit.textContent = t('walkControl.path.durationUnit'); durationWrapper.appendChild(durationInput); durationWrapper.appendChild(durationUnit); durationGroup.appendChild(durationLabel); durationGroup.appendChild(durationWrapper); // ===== 循环播放 ===== const loopGroup = document.createElement('div'); loopGroup.className = 'walk-path-form-group walk-path-form-group-inline'; const loopCheckbox = document.createElement('input'); loopCheckbox.type = 'checkbox'; loopCheckbox.id = 'walk-path-loop-checkbox'; loopCheckbox.className = 'walk-path-checkbox'; loopCheckbox.checked = this.loop; loopCheckbox.onchange = (e) => { // 更新循环播放状态 this.loop = (e.target as HTMLInputElement).checked; }; const loopLabel = document.createElement('label'); loopLabel.htmlFor = 'walk-path-loop-checkbox'; loopLabel.textContent = t('walkControl.path.loop'); loopGroup.appendChild(loopCheckbox); loopGroup.appendChild(loopLabel); section.appendChild(durationGroup); section.appendChild(loopGroup); return section; } /** * 创建漫游点区域 * 包含操作按钮和漫游点列表 */ private createPointsSection(): HTMLElement { const section = document.createElement('div'); section.className = 'walk-path-points-section'; // ===== 操作栏 ===== const toolbar = document.createElement('div'); toolbar.className = 'walk-path-points-toolbar'; // 添加漫游点按钮 const addBtn = document.createElement('button'); addBtn.className = 'walk-path-btn walk-path-btn-small'; addBtn.textContent = `+ ${t('walkControl.path.addPoint')}`; addBtn.onclick = () => this.addPoint(); // 删除全部按钮 const deleteAllBtn = document.createElement('button'); deleteAllBtn.className = 'walk-path-btn walk-path-btn-small walk-path-btn-danger'; deleteAllBtn.textContent = t('walkControl.path.deleteAll'); deleteAllBtn.onclick = () => this.deleteAllPoints(); toolbar.appendChild(addBtn); toolbar.appendChild(deleteAllBtn); section.appendChild(toolbar); // ===== 漫游点列表 ===== const list = document.createElement('div'); list.className = 'walk-path-points-list'; if (this.points.length === 0) { // 空状态提示 const empty = document.createElement('div'); empty.className = 'walk-path-empty'; empty.textContent = t('walkControl.path.noPoints'); list.appendChild(empty); } else { // 渲染每个漫游点 this.points.forEach((_, index) => { const item = this.createPointItem(index); list.appendChild(item); }); } section.appendChild(list); return section; } /** * 创建单个漫游点项 * @param index 漫游点索引 */ private createPointItem(index: number): HTMLElement { const item = document.createElement('div'); item.className = 'walk-path-point-item'; // 如果是当前播放的点,添加高亮样式 if (this.playingPointIndex === index) { item.classList.add('walk-path-point-item-active'); } // 漫游点名称 const name = document.createElement('span'); name.className = 'walk-path-point-name'; name.textContent = `${t('walkControl.path.point')}${index}`; // 操作按钮容器 const actions = document.createElement('div'); actions.className = 'walk-path-point-actions'; // 跳转按钮 const jumpBtn = document.createElement('button'); jumpBtn.className = 'walk-path-btn-icon'; jumpBtn.innerHTML = '▶'; jumpBtn.title = t('walkControl.path.play'); jumpBtn.onclick = (e) => { e.stopPropagation(); this.jumpToPoint(index); }; // 删除按钮 const deleteBtn = document.createElement('button'); deleteBtn.className = 'walk-path-btn-icon walk-path-btn-icon-danger'; deleteBtn.innerHTML = '×'; deleteBtn.onclick = (e) => { e.stopPropagation(); this.deletePoint(index); }; actions.appendChild(jumpBtn); actions.appendChild(deleteBtn); item.appendChild(name); item.appendChild(actions); return item; } /** * 创建播放按钮 */ private createPlayButton(): HTMLElement { const playBtn = document.createElement('button'); playBtn.className = 'walk-path-btn walk-path-btn-play'; playBtn.textContent = `▶ ${t('walkControl.path.play')}`; // 如果没有漫游点,禁用按钮 playBtn.disabled = this.points.length === 0; playBtn.onclick = () => this.playPath(); return playBtn; } // ==================== 操作方法 ==================== /** * 添加漫游点 * 将当前相机位置添加为新的漫游点 */ private addPoint(): void { // 调用引擎添加漫游点 this.registry.engine3d?.pathRoamingAddPoint(); // 更新本地状态 const newIndex = this.points.length; this.points.push({ index: newIndex }); // 重新渲染 this.render(); } /** * 删除指定漫游点 * @param index 要删除的漫游点索引 */ private deletePoint(index: number): void { // 调用引擎删除漫游点 this.registry.engine3d?.pathRoamingRemovePoint(index); // 从本地列表中移除 this.points.splice(index, 1); // 重新索引 this.points.forEach((p, i) => p.index = i); // 重新渲染 this.render(); } /** * 删除所有漫游点 */ private deleteAllPoints(): void { // 调用引擎清除所有漫游点 this.registry.engine3d?.pathRoamingClearPoints(); // 清空本地列表 this.points = []; // 重新渲染 this.render(); } /** * 跳转到指定漫游点 * @param index 目标漫游点索引 */ private jumpToPoint(index: number): void { this.registry.engine3d?.pathRoamingJumpToPoint(index); } /** * 播放漫游 * 按顺序播放所有漫游点 */ private playPath(): void { // 检查是否有漫游点 if (this.points.length === 0) return; // 标记开始播放 this.isPlaying = true; // 调用引擎播放 this.registry.engine3d?.pathRoamingPlay({ duration: this.duration, loop: this.loop, onPointComplete: (pointIndex: number) => { // 更新当前播放点索引 this.playingPointIndex = pointIndex; // 重新渲染以更新高亮状态 this.render(); }, onComplete: () => { // 播放完成,重置状态 this.isPlaying = false; this.playingPointIndex = -1; // 重新渲染以移除高亮 this.render(); } }); } // ==================== 生命周期 ==================== /** * 应用主题 * @param theme 主题配置 */ public setTheme(theme: ThemeConfig): void { if (!this.element) return; // 设置 CSS 变量 this.element.style.setProperty('--bim-text-primary', theme.textPrimary ?? '#fff'); this.element.style.setProperty('--bim-text-secondary', theme.textSecondary ?? '#94a3b8'); this.element.style.setProperty('--bim-bg-elevated', theme.bgElevated ?? '#1f2d3e'); this.element.style.setProperty('--bim-border-default', theme.borderDefault ?? '#334155'); this.element.style.setProperty('--bim-primary', theme.primary ?? '#3b82f6'); } /** * 销毁组件 * 清理订阅和 DOM 元素 */ public destroy(): void { // 取消订阅 this.unsubscribeLocale?.(); this.unsubscribeTheme?.(); // 移除 DOM 元素 if (this.element?.parentElement) { this.element.parentElement.removeChild(this.element); } } } ``` **验收标准**: - [x] 面板显示正确 - [x] 打开时加载已有漫游点 - [x] 添加/删除漫游点功能正常 - [x] 播放时高亮当前点 - [x] 每个方法都有注释 --- ### 任务 6: 添加样式文件 **新建文件**: `src/components/walk-path-panel/index.css` ```css /* 路径漫游面板根容器 */ .walk-path-panel { padding: 16px; display: flex; flex-direction: column; gap: 16px; height: 100%; box-sizing: border-box; } /* ==================== 按钮样式 ==================== */ /* 按钮通用样式 */ .walk-path-btn { padding: 10px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all 0.2s; } /* 播放按钮 */ .walk-path-btn-play { background: var(--bim-primary, #3b82f6); color: #fff; width: 100%; } .walk-path-btn-play:hover:not(:disabled) { opacity: 0.9; } .walk-path-btn-play:disabled { opacity: 0.5; cursor: not-allowed; } /* 小按钮 */ .walk-path-btn-small { padding: 6px 12px; font-size: 12px; background: var(--bim-bg-elevated, #1f2d3e); color: var(--bim-text-primary, #fff); border: 1px solid var(--bim-border-default, #334155); } .walk-path-btn-small:hover { background: var(--bim-border-default, #334155); } /* 危险按钮(删除) */ .walk-path-btn-danger { color: #ef4444; } /* ==================== 表单样式 ==================== */ /* 设置区域 */ .walk-path-settings { display: flex; flex-direction: column; gap: 12px; } /* 表单组 */ .walk-path-form-group { display: flex; flex-direction: column; gap: 6px; } .walk-path-form-group label { font-size: 12px; color: var(--bim-text-secondary, #94a3b8); } /* 行内表单组(复选框) */ .walk-path-form-group-inline { flex-direction: row; align-items: center; } /* 输入框 */ .walk-path-input { padding: 8px 12px; border: 1px solid var(--bim-border-default, #334155); border-radius: 4px; background: var(--bim-bg-elevated, #1f2d3e); color: var(--bim-text-primary, #fff); font-size: 14px; width: 100%; box-sizing: border-box; } .walk-path-input:focus { outline: none; border-color: var(--bim-primary, #3b82f6); } /* 输入框包装器(带单位) */ .walk-path-input-wrapper { display: flex; align-items: center; gap: 8px; } .walk-path-input-wrapper .walk-path-input { flex: 1; } /* 单位文本 */ .walk-path-unit { color: var(--bim-text-secondary, #94a3b8); font-size: 14px; } /* 复选框 */ .walk-path-checkbox { width: 16px; height: 16px; margin-right: 8px; } /* ==================== 漫游点区域 ==================== */ /* 漫游点区域容器 */ .walk-path-points-section { flex: 1; display: flex; flex-direction: column; gap: 12px; min-height: 0; } /* 操作栏 */ .walk-path-points-toolbar { display: flex; gap: 8px; } /* 漫游点列表 */ .walk-path-points-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; } /* 空状态提示 */ .walk-path-empty { text-align: center; padding: 32px 16px; color: var(--bim-text-secondary, #94a3b8); font-size: 14px; } /* ==================== 漫游点项 ==================== */ /* 漫游点项容器 */ .walk-path-point-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; background: var(--bim-bg-elevated, #1f2d3e); border-radius: 4px; transition: all 0.2s; } .walk-path-point-item:hover { background: var(--bim-border-default, #334155); } /* 悬浮时显示操作按钮 */ .walk-path-point-item:hover .walk-path-point-actions { opacity: 1; } /* 播放中的点高亮样式 */ .walk-path-point-item-active { background: var(--bim-primary, #3b82f6); } .walk-path-point-item-active .walk-path-point-name { color: #fff; } .walk-path-point-item-active .walk-path-point-actions { opacity: 1; } /* 漫游点名称 */ .walk-path-point-name { font-size: 14px; color: var(--bim-text-primary, #fff); } /* 操作按钮容器 */ .walk-path-point-actions { display: flex; gap: 8px; opacity: 0; transition: opacity 0.2s; } /* 图标按钮 */ .walk-path-btn-icon { width: 28px; height: 28px; border: none; border-radius: 4px; background: transparent; color: var(--bim-text-primary, #fff); cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; } .walk-path-btn-icon:hover { background: rgba(255, 255, 255, 0.1); } /* 危险图标按钮 */ .walk-path-btn-icon-danger:hover { background: rgba(239, 68, 68, 0.2); color: #ef4444; } ``` **验收标准**: - [x] 样式文件已创建 - [x] 使用 CSS 变量适配主题 - [x] 播放中的点有高亮样式 - [x] 悬浮效果正常 - [x] 每个样式块都有注释 --- ### 任务 7: 构建验证 **执行命令**: ```bash npm run build ``` **验收标准**: - [x] 构建成功 - [x] 无 TypeScript 错误 --- ## 文件修改清单 | 文件 | 操作 | 说明 | |------|------|------| | `src/locales/types.ts` | 修改 | 添加 path 字段类型 | | `src/locales/zh-CN.ts` | 修改 | 添加中文翻译 | | `src/locales/en-US.ts` | 修改 | 添加英文翻译 | | `src/components/walk-path-panel/types.ts` | 新建 | 类型定义 | | `src/components/engine/index.ts` | 修改 | 添加 6 个 pathRoaming 方法 | | `src/managers/engine-manager.ts` | 修改 | 添加 6 个代理方法 | | `src/components/walk-path-panel/index.ts` | 重写 | 完整面板实现 | | `src/components/walk-path-panel/index.css` | 新建 | 样式文件 | --- ## 提交策略 | 任务 | 提交信息 | |------|----------| | 1 | `feat(walk-path): add locale strings` | | 2 | `feat(walk-path): add types` | | 3-4 | `feat(engine): add pathRoaming methods` | | 5-6 | `feat(walk-path): implement panel with highlight` |