📌 博客公告:专注分享编程技术、开发技巧与实战经验~欢迎交流学习!
首页 GitHub 联系我

PPP233

基于NyaDeskPet的二次开发 软件开发与创新日志#1

NyaDeskPet 二次开发优化文档

本文是基于 GitHub 开源项目 NyaDeskPet 进行的二次开发。

该项目是基于 Live2D + AI Agent 的跨平台桌面宠物应用,拥有完全开源的模块化桌宠框架,支持 Live2D 交互、内置 AI Agent。

项目集成了 Live2D 动画、智能聊天、语音识别、文本转语音等丰富功能。

本文将从实际使用中遇到的问题出发进行优化改进,并为后续二次开发提供方向参考。

值得注意的是,笔者非专业开发人员,文中若存在错误,欢迎指正。

原项目地址:NyaDeskPet 仓库

一、运行环境

  • Node.js:项目核心运行环境
  • Electron:跨平台桌面应用开发框架
  • 支持平台:Windows、macOS、Linux

二、项目技术栈

前端技术

  • HTML5:负责应用页面结构搭建
  • CSS3:负责页面样式设计,支持透明效果、半透明滚动条
  • JavaScript/TypeScript:实现核心业务逻辑

核心框架

  • Electron:桌面应用核心框架,采用进程分离架构
    • 主进程:src/main.ts,负责窗口管理、IPC 通信等核心操作
    • 渲染进程:renderer 目录,负责 UI 界面渲染

其他核心技术

  • Live2D:桌面宠物动画渲染核心
    • 依赖库:pixi.min.js、live2dcubismcore.min.js、pixi-live2d-cubism4.min.js
  • WebSockets:用于前后端实时通信
  • TTS :文本转语音功能
  • ASR :语音识别功能

(还有部分技术笔者可能并未涵盖完全,欢迎补充)

三、运行截图

chat-commands

demo-plugin-2

demo-plugin-1

四、问题、重构思路与优化方案

问题 1:角色边缘割裂问题

问题描述:移动角色至程序窗口边缘时,模型会被窗口裁剪(附图),呈现凭空消失的视觉效果。

prem

解决方案
一开始想到的解决办法是增大程序窗口铺满整个屏幕,这样无论如何移动角色都不会被边缘裁剪,因为移出了电脑屏幕本来就看不见了……

bbbg

但随之而来的问题是此时窗口会覆盖整个屏幕,导致鼠标无法正常点击到桌面的其它程序,就相当该程序一直作为主控程序而"霸占"了鼠标

otherpr

于是想到的另一个方法:将窗口跟随角色移动,且大小也随角色大小调整(刚好容纳下角色模型),这样既保证了无论如何移动角色也不会出现被窗口边缘"割裂"的情况,也保证了程序窗口"占地面积"不会过大,让鼠标能够正常点击其它程序。最后为了美观,将上下黑色边框设置为透明,并将原先界面上的各种按钮收纳进了一个按钮。下面是针对该问题的前后实现:

1.实现窗口跟随角色移动 src\main.ts

点击查看代码
// IPC 通信处理
ipcMain.handle('set-window-bounds', (_event: IpcMainInvokeEvent, bounds: Partial<Electron.Rectangle>) => {
  if (!mainWindow) return;
  const current = mainWindow.getBounds();
  mainWindow.setBounds({
    x: bounds.x ?? current.x,
    y: bounds.y ?? current.y,
    width: bounds.width ?? current.width,
    height: bounds.height ?? current.height
  });
});

ipcMain.handle('move-window', (_event: IpcMainInvokeEvent, deltaX: number, deltaY: number) => {
  if (!mainWindow) return;
  const bounds = mainWindow.getBounds();
  mainWindow.setBounds({
    x: Math.round(bounds.x + deltaX),
    y: Math.round(bounds.y + deltaY),
    width: bounds.width,
    height: bounds.height
  });
});

2.实现窗口跟随角色缩放 renderer\js\live2d-manager.ts

点击查看代码
    // 使用原始模型尺寸
    let modelWidth: number;
    let modelHeight: number;
    
    if (this.originalModelBounds) {
      modelWidth = this.originalModelBounds.width;
      modelHeight = this.originalModelBounds.height;
    } else {
      const currentScale = model.scale.x||1;
      modelWidth = model.width / currentScale;
      modelHeight = model.height / currentScale;
    }
    
    // 默认初始缩放
    if (this.baseScale === 1.0) {
      const { width: screenWidth, height:screenHeight } = screen;
      const targetHeight = screenHeight*0.4; 
      this.baseScale = targetHeight/modelHeight;
    }

3.隐藏上下边框并重构UI按钮到右下角的可展开卡片中 renderer\index.html

点击查看代码
    <!-- 可展开的功能卡片 -->
    <div id="function-card" class="function-card collapsed">
      <button id="btn-card-toggle" class="card-toggle-btn">
        <i data-lucide="more-vertical"></i>
      </button>
      <div class="card-content">
        <div class="card-buttons">
          <button id="btn-agent-card" class="card-btn hidden" data-i18n-title="topBar.agent">
            <i data-lucide="bot"></i>
          </button>
          <button id="btn-plugins-card" class="card-btn" data-i18n-title="topBar.plugins">
            <i data-lucide="puzzle"></i>
          </button>
          <button id="btn-settings-card" class="card-btn" data-i18n-title="topBar.settings">
            <i data-lucide="settings"></i>
          </button>
          <button id="btn-open-chat-card" class="card-btn" data-i18n-title="bottomBar.openChat">
            <i data-lucide="message-circle"></i>
          </button>
          <button id="btn-minimize-card" class="card-btn" data-i18n-title="topBar.minimize">
            <i data-lucide="minus"></i>
          </button>
          <button id="btn-close-card" class="card-btn" data-i18n-title="topBar.close">
            <i data-lucide="x"></i>
          </button>
        </div>
      </div>
    </div>

"可展开卡片"的样式表 renderer\styles.css

点击查看代码
/* 功能卡片 */
.function-card {
  position: fixed;
  bottom: 10px;
  right: 10px;
  display: flex;
  flex-direction: column-reverse;
  align-items: flex-end;
  z-index: 2000;
  pointer-events: auto;
}

.card-toggle-btn {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  border: none;
  background: rgba(255, 255, 255, 0.8);
  backdrop-filter: blur(5px);
  box-shadow: 0 2px 8px rgba(0,0,0,0.15);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-top: 5px;
  color: #555;
  transition: all 0.2s;
}

.card-toggle-btn:hover {
  background: white;
  transform: scale(1.1);
}

.card-content {
  background: rgba(255, 255, 255, 0.85);
  backdrop-filter: blur(8px);
  border-radius: 12px;
  padding: 8px;
  box-shadow: 0 4px 16px rgba(0,0,0,0.15);
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  transform-origin: bottom right;
  opacity: 1;
  transform: scale(1);
}

.function-card.collapsed .card-content {
  opacity: 0;
  transform: scale(0.8);
  pointer-events: none;
}

.card-buttons {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.card-btn {
  width: 36px;
  height: 36px;
  border-radius: 8px;
  border: none;
  background: transparent;
  color: #444;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 0.2s;
}

.card-btn:hover {
  background: rgba(0,0,0,0.08);
  color: #000;
}

.card-btn.hidden {
  display: none;
}

.card-btn svg {
  width: 20px;
  height: 20px;
}

#btn-close-card:hover {
  background: #f44336;
  color: white;
}

.toggle-ui-btn {
  display: none;
}

为按钮添加展开时的页面交互 renderer\js\renderer.ts

点击查看代码
function updateAgentButtonVisibility(): void {
  const btnAgent = document.getElementById('btn-agent');
  const btnAgentCard = document.getElementById('btn-agent-card');
  if (!btnAgent && !btnAgentCard) return;

  const settings = window.settingsManager.getSettings();
  if (settings.backendMode === 'builtin') {
    btnAgent?.classList.remove('hidden');
    btnAgentCard?.classList.remove('hidden');
  } else {
    btnAgent?.classList.add('hidden');
    btnAgentCard?.classList.add('hidden');
  }
}

function setupFunctionCard(): void {
  const card = document.getElementById('function-card');
  const toggleBtn = document.getElementById('btn-card-toggle');
  
  if (toggleBtn && card) {
    toggleBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      card.classList.toggle('collapsed');
      
      //切换图标<----
      const icon = toggleBtn.querySelector('i');
      if (icon) {
        const isCollapsed = card.classList.contains('collapsed');
        icon.setAttribute('data-lucide', isCollapsed ? 'more-vertical' : 'chevron-down');
        if (window.lucide) {
          window.lucide.createIcons();
        }
      }
    });
  }

  //点击卡片外区域关闭卡片
  document.addEventListener('click', (e) => {
    if (card && !card.contains(e.target as Node)) {
      card.classList.add('collapsed');
      const icon = toggleBtn?.querySelector('i');
      if (icon) {
        icon.setAttribute('data-lucide', 'more-vertical');
        if (window.lucide) {
          window.lucide.createIcons();
        }
      }
    }
  });

  //绑定卡片内按钮事件
  const btnAgent = document.getElementById('btn-agent-card');
  if (btnAgent) btnAgent.addEventListener('click', showAgentPanel);

  const btnPlugins = document.getElementById('btn-plugins-card');
  if (btnPlugins) btnPlugins.addEventListener('click', showPluginsPanel);

  const btnSettings = document.getElementById('btn-settings-card');
  if (btnSettings) btnSettings.addEventListener('click', showSettingsPanel);

  const btnChat = document.getElementById('btn-open-chat-card');
  if (btnChat) btnChat.addEventListener('click', () => {
    const chatWindow = document.getElementById('chat-window');
    if (chatWindow?.classList.contains('hidden')) {
      showChatWindow();
    } else {
      hideChatWindow();
    }
  });

  const btnMinimize = document.getElementById('btn-minimize-card');
  if (btnMinimize) btnMinimize.addEventListener('click', () => window.electronAPI.minimizeWindow());

  const btnClose = document.getElementById('btn-close-card');
  if (btnClose) btnClose.addEventListener('click', () => {
    if (confirm(window.i18nManager.t('messages.confirmClose'))) {
      window.electronAPI.closeWindow();
    }
  });
}

实现效果截图:

tempe

问题 2:聊天对话框遮挡角色问题

问题描述:原项目设计的对话框必须从窗口右侧向左侧展开,在一定程度上对角色进行了遮挡(让我不能在聊天的同时欣赏角色),沉浸感有所下降

prolm

解决方案
将聊天框的背景,按钮都设置为透明,但实际体验发现依然对角色存在遮挡,于是又将聊天框设置成向右展开,实际上的实现思路就是点击"打开聊天"按钮时向右拓展程序窗口面积,并将聊天栏放置于此,再在收起聊天栏时返回的原来的窗口大小

聊天窗口透明化 renderer/styles.css
下面列举了一些样式表的透明化修改:

  • 第247行 : .chat-sidebar 背景设置为透明
点击查看代码
.chat-sidebar {
  position: absolute;
  top: 0;
  right: -320px;
  width: 280px;
  height: 100%;
  background: transparent !important;
  box-shadow: -4px 0 16px rgba(0, 0, 0, 0.2);
  display: flex;
  flex-direction: column;
  z-index: 9998;
  -webkit-app-region: no-drag;
  transition: right 0.3s ease, visibility 0s 0.3s;
  visibility: hidden;
  pointer-events: none;
}
  • 第274行 : .chat-sidebar-header 背景设置为透明
点击查看代码
.chat-sidebar-header {
  padding: 0 8px 0 0;
  background: transparent;
  color: white;
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-shrink: 0;
  min-height: 50px;
}
  • 第356行 : .conversation-dropdown 背景设置为透明
点击查看代码
.conversation-dropdown {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.25s ease;
  background: transparent;
  border-bottom: 1px solid transparent;
}

实现窗口右侧展开 src/main.ts

点击查看代码
  if (state.chatOpen !== undefined) {
    isChatOpen = state.chatOpen;
    if (mainWindow) {
      const bounds = mainWindow.getBounds();
      if (state.chatOpen) {
        // 打开聊天窗口,增加窗口宽度
        const chatWidth = 280; 
        mainWindow.setBounds({
          x: bounds.x,
          y: bounds.y,
          width: bounds.width + chatWidth,
          height: bounds.height
        });
      } else {
        // 关闭聊天窗口,恢复窗口宽度
        const chatWidth = 280;
        mainWindow.setBounds({
          x: bounds.x,
          y: bounds.y,
          width: Math.max(bounds.width - chatWidth, 300),
          height: bounds.height
        });
      }
    }
  }

实现效果图:

cemia

问题 3:Windows 系统兼容性问题

问题描述:原项目在部分 Windows 系统中运行报错,文件 NyaDeskPet-main\plugins\ui-automation-plugin\main.py 因包含不兼容的表情符号,且系统不支持 UTF-8 编码,导致脚本无法正常执行(也许原作者仅在 macOS 开发环境下进行过测试)。

解决方案:直接删除该文件第 41 行与第 611 行的错误表情符号,就修复了 Windows 系统兼容性问题。

五、总结

遇到的难点

本次二次开发的核心难点在于 Electron 窗口的动态适配与 Live2D 模型的联动控制,需平衡窗口大小、角色位置与用户操作体验,同时解决跨平台编码兼容性问题,界面交互的重构需兼顾功能实现与视觉美观,对我这样非专业开发者而言,调试进程通信和窗口渲染逻辑也耗费了大量时间。

逆向工程的一些思考

在对 NyaDeskPet 进行逆向分析与二次开发的过程中,我深刻体会到开源项目模块化架构的重要性。原项目采用 Electron 框架,实现模块化设计,这为二次开发提供了很大的便捷。

对于跨平台开发的兼容性也同样值得注意,原作者仅针对 macOS 开发,未充分适配 Windows 系统的编码与环境,导致基础功能异常。

这也提醒我们,二次开发不仅是功能修改,更要完善环境适配、提升用户体验。

posted on 2026-03-12 03:18  wobuxiangxiedaima  阅读(252)  评论(0)    收藏  举报

📝 本文为原创文章,转载请注明出处~

💻 博客园 | 你的用户名

⭐ 感谢你的阅读与支持!