尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

【个人学习||Electron桌宠项目实战】3 自带眼动局限性,全局鼠标跟随

【个人学习||Electron桌宠项目实战】3 自带眼动局限性,全局鼠标跟随 1. 为什么自带的眼动追踪不够用做 Electron 桌宠模型能稳定显示之后下一步就是让它“活”起来。静态挂件和会跟随鼠标的角色体验差距非常大。我一开始也想省事直接用 Live2D 库自带的交互逻辑跑起来确实快但很快就撞到了天花板。自带眼动的捕捉范围是窗口内部。也就是说鼠标一旦移出主窗口眼动就断了。除非把窗口铺满整个屏幕否则角色永远只能“看”到窗口里那一小块区域。但铺满屏幕又会带来新问题鼠标穿透逻辑变复杂窗口层级和事件冲突的概率直线上升后面加背景、加拖拽都会互相打架。所以结论很明确把交互逻辑接管过来自己算眼动。核心思路是把职责拆开——主进程负责拿全局鼠标坐标渲染层只负责用这些坐标去算模型参数。这样角色就能对整个桌面上的鼠标移动做出反应而不是被困在窗口矩形里。这一篇要交付的就是这套可复制的方案全局鼠标监听、窗口坐标换算、参数映射以及鼠标穿透开关的验证动作。目标只有一个——让桌宠的眼睛稳定跟随系统光标同时不挡住桌面操作。2. TaoToken 前置给桌宠接一个能对话的大脑眼动解决的是“看”但桌宠要真正有生命感还得能“说”。我后面给角色接了一个对话能力用的是 TaoToken 的 API。它的接口格式和主流大模型兼容改一行 base_url 就能接进来对 Electron 项目很友好。如果你也想让桌宠能聊天、能回答简单问题可以先去 TaoToken 官网 了解一下然后在 API Keys 页面 生成一个 Key。接入文档在 这里里面有完整的请求示例。这一步不是本篇的重点但建议先配好因为后面眼动和对话会共用同一套 IPC 通道。你可以先在 模型对话 里试一下模型返回是否正常确认 Key 能用再往下走。3. 可复制配置全局鼠标监听与坐标换算3.1 主进程读取全局鼠标位置Electron 的screen.getCursorScreenPoint()能拿到系统级鼠标坐标这是整套方案的基础。我把它放在主窗口模块里用一个定时器轮询而不是监听页面事件。// main.js const { app, BrowserWindow, screen, ipcMain } require(electron); let win; let mouseTimer null; function createWindow() { win new BrowserWindow({ width: 400, height: 500, transparent: true, frame: false, alwaysOnTop: true, webPreferences: { preload: path.join(__dirname, preload.js), contextIsolation: true, }, }); win.loadFile(index.html); startGlobalMouseTracking(); } function startGlobalMouseTracking() { if (mouseTimer) clearInterval(mouseTimer); mouseTimer setInterval(() { if (!win || win.isDestroyed()) return; const cursor screen.getCursorScreenPoint(); const bounds win.getBounds(); // 换算成相对窗口左上角的坐标 const windowX cursor.x - bounds.x; const windowY cursor.y - bounds.y; win.webContents.send(global-mouse-move, { x: windowX, y: windowY, screenX: cursor.x, screenY: cursor.y, }); }, 16); // 约 60fps } app.whenReady().then(createWindow); app.on(window-all-closed, () { if (mouseTimer) clearInterval(mouseTimer); if (process.platform ! darwin) app.quit(); });这里有几个参数值得注意。轮询间隔设成 16ms 是为了跟屏幕刷新率对齐太慢会卡顿太快会吃 CPU。win.getBounds()返回的是窗口在屏幕上的位置和尺寸用它减去光标坐标就得到窗口内坐标。注意getBounds()拿到的是包含边框的矩形如果你的窗口有阴影或透明边距可能需要用getContentBounds()做微调。3.2 preload 暴露 IPC 通道渲染层不能直接访问 Node 模块需要通过 preload 桥接。// preload.js const { contextBridge, ipcRenderer } require(electron); contextBridge.exposeInMainWorld(electronAPI, { onGlobalMouseMove: (callback) { ipcRenderer.on(global-mouse-move, (event, data) callback(data)); }, setIgnoreMouseEvents: (ignore, options) { ipcRenderer.send(set-ignore-mouse-events, ignore, options); }, });3.3 渲染层映射到 Live2D 参数拿到窗口内坐标后不要直接把它当成角度。我的做法是先归一化到[-1, 1]区间再乘以灵敏度系数。// renderer.js const model await Live2DModel.from(model/xxx.model3.json); model.autoInteract false; // 关键关掉默认交互避免抢夺控制权 const EYE_SENSITIVITY 1.0; const HEAD_SENSITIVITY 0.6; const BODY_SENSITIVITY 0.3; window.electronAPI.onGlobalMouseMove(({ x, y }) { const winW window.innerWidth; const winH window.innerHeight; // 归一化到 [-1, 1] const nx (x / winW) * 2 - 1; const ny (y / winH) * 2 - 1; // 限制范围避免参数越界导致模型变形 const clamp (v, min, max) Math.min(max, Math.max(min, v)); model.internalModel.coreModel.setParameterValueById( ParamEyeBallX, clamp(nx * EYE_SENSITIVITY, -1, 1) ); model.internalModel.coreModel.setParameterValueById( ParamEyeBallY, clamp(-ny * EYE_SENSITIVITY, -1, 1) ); model.internalModel.coreModel.setParameterValueById( ParamAngleX, clamp(nx * 30 * HEAD_SENSITIVITY, -30, 30) ); model.internalModel.coreModel.setParameterValueById( ParamAngleY, clamp(-ny * 30 * HEAD_SENSITIVITY, -30, 30) ); model.internalModel.coreModel.setParameterValueById( ParamBodyAngleX, clamp(nx * 10 * BODY_SENSITIVITY, -10, 10) ); model.internalModel.coreModel.setParameterValueById( ParamAngleZ, clamp(nx * 10 * BODY_SENSITIVITY, -10, 10) ); });这里ParamEyeBallY和ParamAngleY用了负号因为屏幕坐标 Y 轴向下而模型参数通常向上为正。这个符号很容易搞反调试时如果发现眼睛上下颠倒先检查这里。3.4 鼠标穿透开关桌宠窗口如果一直吃鼠标事件用户点桌面图标都会被挡住。我的做法是鼠标落在模型范围内时窗口接收事件落在空白区域时允许穿透。// renderer.js 继续 const canvas document.getElementById(live2d-canvas); canvas.addEventListener(mousemove, (e) { const rect canvas.getBoundingClientRect(); const px e.clientX - rect.left; const py e.clientY - rect.top; // 用 canvas 的 alpha 通道判断是否命中模型 const ctx canvas.getContext(2d); const pixel ctx.getImageData(px, py, 1, 1).data; const isOnModel pixel[3] 0; window.electronAPI.setIgnoreMouseEvents(!isOnModel, { forward: true }); });主进程侧接收这个信号// main.js 补充 ipcMain.on(set-ignore-mouse-events, (event, ignore, options) { const win BrowserWindow.fromWebContents(event.sender); if (win) win.setIgnoreMouseEvents(ignore, options); });forward: true这个选项很关键。它让窗口在穿透状态下仍然能收到mousemove事件否则一旦穿透就再也检测不到鼠标回到模型上了会陷入死循环。4. 验证请求与成功结果配置写完后按下面步骤验证。第一步启动 Electron 应用确认窗口正常显示模型加载完成。打开开发者工具在 Console 里执行window.electronAPI.onGlobalMouseMove((data) console.log(data));然后移动鼠标如果能看到坐标持续打印说明主进程到渲染层的通道通了。第二步把鼠标移到窗口外观察模型眼睛是否还在跟随。如果眼睛在窗口边缘停住不动说明autoInteract没关掉或者归一化计算有问题。第三步验证穿透。把鼠标移到模型身体上点击桌面图标应该点不到把鼠标移到模型旁边的透明区域再点桌面图标应该能正常选中。如果穿透失效检查getImageData是否因为跨域或 canvas 污染报错。实测下来这套方案在 400x500 的小窗口上 CPU 占用大约 3% 到 5%轮询间隔调到 32ms 可以再降一半但跟随会有轻微延迟。你可以根据自己的机器性能权衡。5. 本篇常见错排查问题一眼睛跟随方向反了。最常见的原因是 Y 轴符号。屏幕坐标向下为正模型参数向上为正所以ParamEyeBallY和ParamAngleY需要取负。如果左右也反了检查 X 轴归一化公式是不是写成了(x / winW) * 2 - 1的反向。问题二鼠标移出窗口后眼睛不动。先确认model.autoInteract false已经设置。如果设置了还是不动检查主进程的setInterval是否真的在跑可以在回调里加一行console.log确认。另外win.getBounds()在窗口拖动后需要重新获取不能缓存。问题三穿透后鼠标回不到模型上。这是forward选项没开。setIgnoreMouseEvents(true, { forward: true })必须带第二个参数否则窗口完全忽略鼠标连mousemove都收不到。问题四模型参数越界导致变形。Live2D 的参数都有合法范围比如ParamAngleX通常是 -30 到 30。归一化后的值乘以灵敏度可能超出范围所以一定要用clamp限制。我一开始没加头直接转到了背后。问题五多显示器下坐标错乱。screen.getCursorScreenPoint()返回的是虚拟桌面的绝对坐标如果窗口在副屏上getBounds()的坐标也是相对于虚拟桌面的两者相减没问题。但如果你的窗口用了setPosition手动定位要确保坐标系一致。6. 下一步让桌宠能对话眼动和穿透跑通之后角色已经能在桌面上“看”你了。但真正的生命感来自互动——你问它一句话它能回你。这部分我用 TaoToken 的接口接了一个轻量对话模块渲染层把用户输入通过 IPC 发给主进程主进程请求 API 再把结果传回来。如果你也想继续往下做建议先去 API Keys 页面 把 Key 配好然后参考 接入文档 把请求封装成主进程的一个函数。长期做编码和 Agent 方向的话Coding Plan 会更划算一些。眼动这套逻辑本身不复杂难的是坐标换算和穿透状态的边界处理。把这两个坑填平后面加对话、加动作、加拖拽都会顺很多。
返回列表