DSH Web GUI 的 Live2D 桌宠:可拖动、跟随鼠标,跟着会话状态换动作、表情与装扮,右键呼出控制面板。
安装
# npm 包(预构建)
dsh plugin --profile web add dsh-pet-live2d
# GitHub 源码(首次需按提示配置 allowBuilds 构建授权后重试)
dsh plugin --profile web add github:A8Chann/dsh-pet-live2d#path:/dsh-live2d-pet
装任何插件都等于在你的机器上跑第三方代码,权限和你本人一样大——能读你的文件、用你的凭据、访问网络,工具审批管不到它。GitHub 来源的插件还会在安装时执行构建脚本——pnpm 默认拦截,所以安装可能停在 ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED 或 ERR_PNPM_IGNORED_BUILDS;dsh 会打印出需要添加的确切键名,把它加进该 profile 的 pnpm-workspace.yaml 的 allowBuilds 下,重跑一次即可装上。放行构建本身就是一次信任判断:请只安装可信来源,并尽量锁定 commit(github:owner/repo#sha)。
README
给 DSH Web GUI 挂一只 Live2D 桌宠:会呼吸、会眨眼、眼睛和脑袋跟着鼠标转,能拖着换位置,点一下有反应,右键面板里能播模型自带的全部动作与表情。
自带 DS鲸鱼娘(氵六青 的无偿分享模型):8 组动作 + 44 个表情/道具。

功能
| 功能 | 说明 |
|---|---|
| Live2D 渲染 | PixiJS v8 + untitled-pixi-live2d-engine(Cubism 3/4/5),WebGL 透明画布,$O(1)$ 开销的常驻浮层 |
| 鼠标跟随 | 指针在窗口内移动时,模型的眼球/头部实时朝向指针;移开后视线自然回到中心 |
| 拖动与缩放 | 按住角色身上拖走;位置与尺寸存 localStorage,刷新后原样恢复 |
| 点击反应 | 点头部 → 重锤出击 + 脸红 + 台词;点身上其它地方只出气泡,不挥锤 |
| 事件穿透 | 只有角色剪影吃鼠标事件,方形画布的透明区域穿透到底下页面,不挡 DSH 的 UI |
| 零常驻 UI | 画面上只有角色本身;UI 全在右键面板里,Esc 或点面板外关闭 |
| 右键面板 | 宠物身上点右键呼出全部控制:动作 / 表情 / 换宠物 / 大小 / 归位。平时画面上没有任何常驻 UI,鼠标划过也不显示 |
| 动作面板 | 读取模型自己声明的 motion group,8 个动作一键播放 |
| 装扮菜单 | 表情与装扮合并成一个菜单:44 个表情归入 17 个互斥槽位,每槽单选,跨槽位可同时生效。选择常驻;其中六件装扮(眼镜/发饰/魔爪/巴菲/桌布/手机换色)是穿在身上的:会话相位不动它们,「归位」也不清它们,而且跨启动记住(localStorage) |
| 拖动 | 按住角色身上拖走;位置与尺寸存 localStorage。可以一直往下拖,压出屏幕底边(最多沉下去一个身位),模型的画布有透明边距,不放开下界的话角色看着永远悬空 |
| 多宠物 | %DSH_HOME%\pets\ 下所有 renderer: live2d 的宠物都会被扫描,面板里可切换 |
| 零配置宠物 | 宠物 = 一个目录 + pet.json;插件不硬编码任何模型 |
安装
# 从 npm 装(包名 dsh-pet-live2d)
dsh plugin --profile web add dsh-pet-live2d
# 或从本地目录装(开发时用)
dsh plugin --profile web add "link:D:\HTML\DSH_Pet_Live2d\dsh-live2d-pet"
dsh plugin --profile web add "link:<本目录绝对路径>"
装完 重启 dsh web(新 bundle 不参与热重载)。
Cubism Core 运行时(一般不用管)
Live2D 的专有许可不允许再分发 Core 运行时,所以本插件不内置它 ——
但也不需要你去找文件:宿主半区会在本地缺失时,向 Live2D 自己的 CDN 取一份
(https://cubism.live2d.com/sdk-web/cubismcore/live2dcubismcore.min.js,
官方 SDK 文档就是让使用者在页面里引这一行;这个地址带 Access-Control-Allow-Origin: *),
取到后缓存到本地,之后离线也能用:
%DSH_HOME%\pets\.runtime\live2dcubismcore.min.js
想完全离线就自己从 Live2D 官方 Cubism SDK for Web 下载后放到上面那个路径,插件会优先用它。两边都拿不到时(无网络 + 无本地文件), 宠物位置会显示一张安装指引卡,接口返回 502 并带上该地址和落盘路径(不会崩,也不影响其它插件)。
v1.4 UI 重构
画面上不再有任何常驻 UI,鼠标划过也不出现。 所有功能收进右键面板。
| 之前 | 现在 |
|---|---|
| 鼠标划过宠物 → 浮出一条工具栏(面板 / 归位 / - / +) | 划过什么都不显示;宠物身上点右键呼出完整面板 |
| 工具栏浮在角色身上,挡住画面 | 面板在宠物旁边,不盖住角色 |
| 大小只能靠工具栏的 -/+ | 面板底部:- / 滑杆 / + / 当前 px / 归位 |
| 关面板要点同一个按钮 | Esc、右上角 ×、或点面板外任意处都能关 |
为什么去掉 hover:一是浮层压在角色身上很碍眼;二是透明穿透的代理层没法好好表达 hover——根节点是 pointer-events: none,[data-hover] 只能靠代理的 pointerenter/leave 手动维护,指针从宠物移到按钮上那段空隙还会让工具栏闪掉。右键没有这些问题:它是主动动作,而且要拦掉浏览器自带菜单(preventDefault),语义唯一。
面板只对角色剪影响应——在方形画布的透明角落点右键会照常穿透到页面,不会误开面板。
槽位分类(装扮 + 表情)
44 个表情全部归入 17 个互斥槽位(换过宠物/改过配置的话,以面板和 pet.json 为准)。
分类不是按文件名猜的,依据是模型作者在 cdi3 里自己写的分组和中文名——例如 ParamGroup29 被作者命名为「C款动作开关(集中放置方便检查」,
里面正好是 point(点菜板) / danbaofan(蛋包饭) / phone(手机手) / phone7(双手比耶) / maoshou(猫手),一组互斥的手部状态。
表情那 7 个槽位则参考了参数冲突:爱心眼/开心兴奋/悲伤/大哭/生气/晕晕 都会写眉毛参数,放在一起必然打架。
| 槽位 | 不选 | 可选项 |
|---|---|---|
| 眼镜 | 无 | 圆眼镜 / 方眼镜 / 椭圆眼镜 / 墨镜 |
| 贴纸 | 无 | 猫猫 / 兔兔 / 蝴蝶结 |
| 发饰 | 戴着 | 摘掉发箍 / 单边马尾 |
| 桌布 | 白色 | 黑色 |
| 魔爪 | 无 | 粉魔爪 / 白魔爪 |
| 鲸鱼 | 无 | 头顶鲸 / 放桌上 |
| 桌面摆设 | 无 | 巴菲 |
| 右手 | 无 | 掏出手机 / 喵喵手 / 双手比耶 / 挤番茄酱 / 写本本 |
| 左手 | 无 | 撤回 / 画笔 / 橡皮 / 蛋包饭 |
| 眼部 | 默认 | 星星眼 / 爱心眼 / 呆呆眼 / 晕晕 / 阴暗 |
| 情绪 | 平静 | 开心兴奋 / 悲伤 / 大哭 / 生气 / 调皮 / 闭眼口水 / 吐魂 |
| 嘴部 | 闭嘴 | 吐舌 / 吹泡泡糖 |
| 符号 | 无 | 问号 / 感叹号 / 流汗 |
| 氛围 | 无 | 情绪花花 / 心跳 / 冒爱心 |
| 脸红 | 否 | 脸红 |
| 其他 | 无 | 手机换色 |
| 点菜 | 无 | 点菜按下 |
槽位定义在 pet.json 的 live2d.expressionSlots,换宠物只改这里;宿主会用
normaliseSlots() 校验(缺 id、缺表达式的槽位直接丢掉,免得渲染出死按钮)。
一个选项可以带多个表达式。这个模型的 魔爪换色 只改颜色、本身不显示任何东西
(没有爪子可换色),所以「白魔爪」必须同时点亮 桌面粉魔爪 和 魔爪换色:
{ "label": "白魔爪", "expressions": ["桌面粉魔爪", "魔爪换色"] }
选择常驻。早期版本里手动点的表情会在 EXPRESSION_HOLD_MS 后自动清掉,那是为了防止
宠物卡在某个状态;但对装扮来说这是错的——用户从面板挑的搭配过几秒自己消失,看起来就是 bug。
现在自动清理只留给反应和会话相位,面板选择一直保留到「归位」或改选。
装扮:穿在身上的东西
眼镜 / 发饰 / 魔爪 / 巴菲(桌面摆设)/ 桌布 / 手机换色 这六个槽位是装扮(OUTFIT_SLOTS),
跟"这一轮临时挑的表情"不是一回事:
| 场景 | 临时表情 | 装扮 |
|---|---|---|
| 会话相位开始/结束 | 由相位接管,相位结束就撤掉 | 不动 |
| 点「归位」 | 清空 | 保留 |
| 重启 / 刷新页面 | 回到默认 | 从 localStorage 穿回来 |
自动清理(EXPRESSION_HOLD_MS) |
到点清掉 | 从不参与 |
实现上一句话就够:commitPinsRef 里装扮的 pin 最后合并,所以相位的"槽位拥有权"
压不过它;resetAll 只清临时效果;选择一变就写一次 localStorage
(key dsh-live2d-pet:outfit),启动后在 ready 时读回来,并校验 label 在当前 pet.json
里还存在——换模型或改配置之后存档可能对不上,对不上就当没存过,不会凭空造一个选项出来。
多槽位叠加(装扮)
引擎的表情管理器一次只持有一个表情(expressionManager.currentExpression),所以「眼镜 + 猫猫贴纸 + 深色桌布 同时戴」不能交给它。
做法:插件自己按帧写参数。每个表达式在自己的 .exp3.json 里声明了它要写的通道和混合方式(这个模型全是 Add),客户端把选中表达式的通道取并集交给控制器,控制器每帧叠加一次。
关键是挂钩点:写在 loadParameters() 之后是错的——那一帧的 saveParameters() 会把它一起快照进基线,于是下一帧恢复出来时已经含了它,再叠一次,永远关不掉(实测开关停在 1,取消选择也不回落)。正确的点是 saveParameters() 之后:
每帧:loadParameters()(抹掉上一帧的表情层)→ 动作写参数 → saveParameters()(快照)
→ 我们写表情层 → 引擎算形变 → 绘制
引擎自己的表情流程就在 saveParameters() 之后,所以这就是同一层,只是没有「只能有一个」的限制。
实测(cdp-merge,全部从真正的装扮面板点出来):
| 操作 | 结果(帧内参数) |
|---|---|
| 什么都不选 | [0,0,0,0] |
| 点圆眼镜 | [1,0,0,0] |
| 再点猫猫 | [1,1,0,0] —— 眼镜留着 |
| 眼镜槽选「无」 | [0,1,0,0] —— 猫猫留着 |
| 再加深色桌布 | [1,1,1,0] |
附:这一块踩过的测量陷阱
同一个问题我给出过两次互相矛盾的结论,都是被错误的测量方法带偏的,记下来免得重蹈:
| 方法 | 得到 | 我当时的结论 | 真相 |
|---|---|---|---|
| 冻结 rAF + 截图逐像素比对 | 每格 0.00% | 「表情全都不生效」 | 冻结不可靠,后面每格都在读同一张陈旧帧 |
| 比对 canvas 哈希 | changed: true |
「表情是好的,只是要等淡入」 | 宠物一直在呼吸眨眼,任意两帧都不会逐字节相同,信号恒为真 |
可靠的做法是读引擎在帧内写进模型的参数值:确定性、不受动画相位影响。cdp-exp 和 cdp-merge 现在都这么做。
顺带修的:cdp-exp 原本是个打印型 driver——结尾无条件 process.exit(0),从不判定,再加上它唯一的信号(canvas 哈希)恒为真,等于从来没有断言过任何事。现在它真的会失败。
v1.3 修正(本次)
| # | 问题 | 原因 | 处理 |
|---|---|---|---|
| 1 | 重锤出击在摸鱼动画里也会播 | 摸鱼从「所有非待机动作」里随机抽,重锤出击与鲸鱼喷水都在池子里 | 加 FIDGET_DENY 拒绝表;宠物可用 motionOptions[group].fidget 覆盖 |
| 2 | 鲸鱼喷水同上 | 同上 | 同上 |
| 3 | 动作 / 表情播完不回初始待机 | hold: true 是永久定格;手动点的表情也永久钉住 |
ACTION_HOLD_MAX_MS(9s) / EXPRESSION_HOLD_MS(12s) 两道时限 + resetToRest() 收口 |
| 4 | 没接上 DSH 会话流 | 订阅了 tool/call——那是会话日志事件,不是 cordis 事件,永远不触发;而且相位只播一次,长任务看着像没反应 |
改订 tools/pre-execute / tools/post-execute(瀑布事件,必须 next());tool/done/failed 改为 sustain,动作播完自动重播直到相位改变 |
| 5 | 整个方形画布都能拖动、挡住底下 UI | 元素即使全透明,只要 pointer-events: auto 就吃满整个盒子 |
根节点 pointer-events: none + 一层用 clip-path: path() 裁成剪影的不可见代理;透明处穿透(mask-image 不影响命中测试,只有 clip-path 会) |
| 6 | 点身上任何地方都挥锤 | 没有头部区域的概念 | 从模型自己的五官 drawable 量出头部包围盒(模型空间,随缩放/拖动自动跟随),只有点头部才触发重锤出击 |
为什么这些 bug 之前测不出来
回归套件的宿主是假的:attachActivityEvents({ on: () => {} }, hub) 传了一个空的 on,相位全靠 /__nudge 直接推 hub。于是插件自己的事件订阅一行都没被验证过——这正是 tool/call 那个 bug 能带着 10 个绿灯活下来的原因。
现在 harness 提供一个真的小事件总线 + /__emit,cdp-host-events.mjs 用真实事件名驱动并断言。新增 5 个 driver(共 16 个):
| driver | 覆盖 |
|---|---|
cdp-host-events.mjs |
tools/pre-execute 有订阅者、会 next()、能推进相位;长相位持续播放;tools/post-execute / agent/turn-stopping 收尾 |
cdp-head.mjs |
点头部播重锤出击 + 脸红;点身上不播;摸鱼池排除重锤/喷水 |
cdp-idle-return.mjs |
表情 12s 自清;定格 9s 释放;resetToRest();相位表情随相位清除 |
cdp-passthrough.mjs |
四个角都穿透到页面;角落点击不触发反应、不拖动;角色上点击正常送达 |
cdp-bubble.mjs |
动作定格真的能关掉:吹泡泡糖 / 掏出手机 → 无,连做三轮(污染只在第二轮之后现形);断言用 drawn(),并同时钉住"帧外基线仍停在 1"这条缝 |
v1.2 交互与渲染优化
| 问题 | 原因 | 处理 |
|---|---|---|
| 放大后画面模糊 | 渲染缓冲固定 1x,画面被 CSS 拉伸 | resolution = max(2, devicePixelRatio);实测 backing store 恒为 CSS 尺寸的 2–3 倍 |
| 缩小后线条发虚(v1.2.1) | 两道叠加:① 2048² 图集被直接缩到 160–760px,而 lod:"single-auto" 只在 effectiveScale < 0.5 时才做 LOD——300px 时约 0.59,这个分支根本没触发,等于每个屏幕像素只从图集里抽 1 个纹素,细笔画被整根抽掉;② 画布 backing store 在 1x 屏上只有 160–760²,细线本身就落在采样点之间 |
① lod:"full" 建完整 mip 链(注意:lod:false 是"全分辨率但不建 mip",比 "single-auto" 更糟)+ maxAnisotropy: 8;② 渲染倍率下限提到 2x 做超采样。面部细笔画像素占比实测(300px):原始 8.16% → 仅建 mip 3.89% → mip+2x 超采样 5.27%,断线/锯齿消失,细线恢复连续 |
| 画布空白处也能点到 | 整个方形 canvas 都在吃点击 | 从实际渲染出的像素提取 64×64 透明度网格,只有落在角色轮廓上才算点击 |
| 画布挡住底下的 UI(v1.3) | 元素即使透明,只要 pointer-events: auto 就会吃满整个盒子 | 根节点改成 pointer-events: none,另加一层不可见代理,用 clip-path: path(...) 把可点区裁成角色剪影(mask-image 不影响命中测试,只有 clip-path 会影响)。透明处直接穿透到页面;代理轮廓按 hitsMask 的 ±1 格容差膨胀一格,两者严格重合 |
| 鼠标移开后视线不回正 | 视线停在"最后一个指针位置" | 超出注视范围即回到模型默认中心位(data-gaze="center") |
| 不接会话状态 | 只响应点击 | 宿主订阅 agent/status / agent/turn-stopping / agent/error / approval/request,经同源 SSE 推送;客户端按相位切换动作与表情 |
| 打开设置面板宠物被放大 | 真 bug:layout() 用了 model.width,而 Pixi 的 Container.width 返回的是当前缩放后的尺寸,于是每次重排都把缩放自乘一次 |
启动时缓存未缩放原始尺寸,之后一律由它计算;面板开合不再影响画面 |
| 待机太死板 | 没有随机行为 | 静置 12–26 秒后随机播一个非待机动作("摸鱼"),播完自动回待机;任何交互都会重置计时。重锤出击与鲸鱼喷水不在摸鱼池里——它们是「点头」和「出错」的专属反应,被随机播出来就像宠物在回应一件根本没发生的事(FIDGET_DENY,宠物可用 motionOptions[group].fidget 覆盖) |
缩小时的锐度(v1.2.1)

上图为 3 倍最近邻放大的面部区域,顺序是 旧 160px | 新 160px | 旧 300px | 新 300px。旧的渲染里发丝是断续的虚线状、轮廓边上有明显的方块感;新的渲染线条连续、边界干净。
三处改动:
| 项 | 旧 | 新 | 为什么 |
|---|---|---|---|
| 纹理采样 | lod: "single-auto" |
lod: "full" |
"single-auto" 只有 effectiveScale < 0.5 才生效;300px 宠物约 0.59,这条分支从未触发,等于只做双线性点采样。"full" 才会让资源加载器生成完整 mip 链(注意 lod:false 是"全分辨率但不建 mip",比 "single-auto" 更差) |
| 各向异性过滤 | 无 | 各向异性 8x | 引擎不会把 textureOptions.maxAnisotropy 传给采样器,必须在加载后写到每张纹理的 style 上;它负责斜向线条(刘海、缎带边缘)在斜视时不糊成一片 |
| 渲染倍率 | min(3, devicePixelRatio),1x 屏就是 1x |
min(3, max(2, devicePixelRatio)) |
这是最有效的一招:1x 屏上 300px 画布只有 300² 采样点,无论纹理怎么筛,输出就只有这么多样本。下限提到 2x 等于超采样(每个显示像素 4 个渲染样本),再由浏览器缩回 CSS 尺寸 |
实测(300px,面部细笔画像素占该区域的比例):旧 8.16% → 仅建 mip 3.89% → mip + 2x 超采样 5.27%。纯 mip 化会把细线"抹平"(数字反而比旧的低),所以两者必须一起上;超采样把细节拉回来,mip 链保证缩小时不出现摩尔纹和闪烁。
表情(v1.2.2)
44 个表情此前全部加载失败:model3.json 里指向的是中文文件名(expressions/脸红.exp3.json),而磁盘上按 manifest 路径校验的要求已经改成了 ASCII slug(facial-red.exp3.json)——生成器改过,但改完没有重新跑,装到 %DSH_HOME% 的那份是旧的。build-pet.mjs 现在会保留手写文件(pet.json / catalog.json / README.md / voice.json),重跑不会再把这些删掉,可以安全地反复执行。
还有一处命名不一致:哭.exp3.json 在 model3.json 里声明的 Name 是 "大哭"。表情查找按 Name 而非文件名匹配,所以 failed 相位原本写的 "哭" 永远查不到、静默什么都不做。现已修正为 大哭。
会话状态映射(v1.3 修正)
宿主把 DSH 的真实事件折叠成一个粗粒度相位并推送:
| 事件 | 相位 | 默认动作 | 默认表情 | 持续播放 |
|---|---|---|---|---|
agent/status → running |
thinking | 待机 | 呆呆眼 | 否(待机本身就在循环) |
approval/request |
waiting | 待机 | 问号 | 否 |
tools/pre-execute |
tool | 挤番茄酱 | 流汗 | 是 |
agent/turn-stopping |
done | 吹泡泡糖 | 情绪花花 | 是 |
agent/error |
failed | 鲸鱼喷水 | 大哭 | 是 |
为什么会话流之前「没接上」:
tool/call不是 cordis 生命周期事件,而是写进会话记录(transcript)的日志事件——ctx.on('tool/call')永远不会触发,所以工具活动对宠物完全不可见。真正的挂钩点是tools/*瀑布事件(tools/pre-execute/tools/post-execute/tools/execute),它们带着ToolExecution本身。瀑布事件必须调用next(),否则会把整条链断掉,所以订阅器把它包在 try/catch 里、异常时也继续next()。
工具相位不抖动:一轮对话里往往连着跑很多个工具。如果每次工具返回就立刻退回 thinking,相位会一秒翻好几次、动画跟着不停重启。所以退回是防抖的——只有在工具真的不再来了(TOOL_IDLE_MS = 1.2s 内没有新工具)之后才退回。
持续播放:相位是状态而不是一次性事件。tool / done / failed 会由控制器的 sustain 循环在动作播完后自动重播,直到相位改变——否则一个跑了 30 秒的工具调用只会看到 5 秒动画然后回到待机,看起来就像「没反应」。thinking / waiting 落在待机循环上,本身就在动,不重复触发(否则只会显得抽搐)。
可被宠物自己在 pet.json 的 live2d.motions / live2d.expressions 里覆盖(键就是这些相位名)。相位到达时若宠物正在演用户触发的反应,会延后到回待机再补播,不会丢掉状态;SSE 断开时会解除 sustain,不会永久卡在某个相位。
回到待机(v1.3)
要求是「所有动作、表情在播完一段时间后都要完全切回初始待机」。三件事共同保证:
| 情形 | 上限 | 说明 |
|---|---|---|
| 普通动作 | 动作自身 Duration |
播完即回待机循环 |
hold: true 的定格 |
ACTION_HOLD_MAX_MS = 9s |
定格不是永久的:先定格一会儿让人看清,然后交还身体并还原参数 |
| 手动点的表情 | EXPRESSION_HOLD_MS = 12s |
到点自动清除,面板的高亮也跟着消失 |
| 会话相位表情 | 跟相位同寿 | 相位离开时清除 |
resetToRest() 是唯一的收口:清 sustain、还参数、回待机循环。「归位」按钮走的就是它。
观测契约
宠物根节点上有三个属性,便于排查与自动化测试:
data-motion— 当前动作组(待机为idle)data-gaze—center(回默认位)/pointer(跟随鼠标)data-phase— 最近一次会话相位
另外 window.__dshLive2dPet 暴露了动作控制器(maskInfo() 可查看点击轮廓、playOnce() / playIdle() 可手动驱动),方便在控制台排查。
关于 HitAreas
本模型没有声明 Cubism HitAreas,所以点击判定不依赖引擎的 hitTest,而是从渲染结果的 alpha 通道提取轮廓——因此任何模型都能用,无需作者额外导出命中区。
动作状态机
Live2D 的 MotionManager 有三处反直觉行为,直接裸调 model.motion() 会出现「点一下就一直循环播放」这类问题。插件用一个状态机统一接管动作生命周期:
| 引擎行为 | 后果 | 处理 |
|---|---|---|
| 同 group+index 正在播放时拒绝再次启动 | 连点没反应 / 只能播一次 | 每次启动前先 stopAllMotions() |
NORMAL 优先级不能打断 NORMAL |
第一次动作后宠物「死」了,后续全被静默拒绝 | 互动与面板动作用 FORCE,待机用 IDLE |
motion() 是异步的:先 stopAllMotions() 再加载入队,这中间 MotionManager.update 会看到 playing && isFinished() 并误发 motionFinish |
新动作刚开始就被判定「播完了」,瞬间弹回待机 | 忽略启动后 250ms 内到达的 motionFinish |
动作自带 "Loop": true 时永不结束,也就永不触发 motionFinish |
动作无限循环,回不到待机 | 宿主从 motion3.json 读出 Duration/Loop,按声明的时长定时收尾 |
因此状态机只做三件事:常驻待机循环 → 播一次动作 → 自动回待机,且每一步都可被打断。
宿主半区会把每个动作的 duration(毫秒)、loop 以及该动作写了哪些参数(来自模型自己的 motion3.json)随 catalog 下发,所以换任何模型都能自适应,不需要改插件代码。
动作语义(v1.2.2)
引擎还有一条更隐蔽的行为,是「吹泡泡吹完嘴不还原」的根因:
动作结束后,它写过的参数没有任何人负责还原。 引擎在动作播放期间往模型参数里写值,停下就只是「不写了」——参数留在最后一帧的值上。平时看不出问题,是因为待机循环恰好也在驱动这些参数;而本模型的动作专属参数(
chuipaopao*、phone*、pengshui…)待机完全不碰,于是动作一停,最后的嘴形就永久留在脸上。
插件现在的做法:动作启动前把它会写的参数快照下来,回待机时还原。参数名单由宿主从 motion3.json 的 Curves 里读出并下发。
这条「还原」还藏过两个更深的坑,都在这条缝上:
- 引擎的一帧是
saveParameters()→update()→loadParameters(),loadParameters()是最后一个。 我们的图层(表情 / 嘴 / 眨眼 / 扫动画 / 动作还原)写在saveParameters()之后,update()把当时的值烘进模型画出去, 然后帧尾的loadParameters()把引擎自己的基线整片盖回来。所以:- 还原不能「把快照写回参数」——那是帧外写,会被帧尾的
loadParameters()抹掉,看起来就是「切回无也切不回去」。 它现在是一层每帧覆盖。 - 帧外读参数永远拿到"图层之前"的值:动作停了它还是 1、表情明明生效却是 0。 快照若从那里取,第二次吹泡泡糖就会忠实还原成「鼓着的嘴」。快照现在读还原缝上的值(还装着就取还装的值,否则取引擎的值)。
- 还原不能「把快照写回参数」——那是帧外写,会被帧尾的
- 要断言"画面里是什么",只能读
window.__dshLive2dPet.drawn(id)(控制器在钩子里存下的、这一帧真正画出去的值), 或者自己包一层core.update。测试里读帧外的_model.parameters.values是量错了地方——这个错让同一个 bug 骗过两次。
另外全部动作现在都以 loop: false 启动。引擎的合并方式是 setLoop(调用方的 loop ?? 动作自带的 Meta.Loop),而本包所有 motion3.json 都写着 "Loop": true,所以不显式传 false 的话动作永远不结束,也就永远摆不出「定格」姿势。
剩下三件事是模型作者才知道的意图,写在 pet.json 的 live2d.motionOptions 里:
| 声明 | 含义 | 解决的问题 |
|---|---|---|
{"hold": true} |
动作播完定格在最后一帧;最多 ACTION_HOLD_MAX_MS(9s)后交还身体 |
掏出手机后手机能拿在手里看一会儿,但不会永远举着 |
{"fidget": false} |
不参与随机摸鱼 | 把手交互类动作排除出摸鱼池 |
{"prepend": "OpenCase"} |
先播前置动作,再播真正的动作 | 自拍的 phone 第一帧就是 1(作者假定手机已在手),不先掏手机就是在对着空气自拍 |
{"preset": {"jingyu": 1}} |
动作本身没写、但这个动作需要被一起点亮的参数 | 鲸鱼喷水这个动作只写了 pengshui(碰水),真正负责「喷」的鲸鱼是另一个参数 jingyu,不点它看起来就是毫无反应 |
定格姿势不算「忙」(isPlaying() 返回 false):否则点一次掏出手机就会永久压住待机摸鱼和会话相位,宠物就此卡死。它只是「看起来不一样的待机」,任何新动作都能接管。
宠物根节点带 data-motion 属性(待机时为 idle),方便直接观察当前状态。
宠物契约
一个宠物目录长这样:
%DSH_HOME%\pets\<id>\
pet.json # 清单(renderer: live2d)
c_0120.model3.json
model\ # .moc3 / physics3 / cdi3
textures\ # 贴图
motions\ # .motion3.json
expressions\ # .exp3.json
catalog.json # 可选:动作/表情的中文名与分类
pet.json:
{
"petManifestVersion": 2,
"id": "ds-whale-girl",
"displayName": "DS鲸鱼娘",
"renderer": "live2d",
"license": "...", // 资产授权声明
"live2d": {
"model": "c_0120.model3.json", // 相对于本目录
"scale": 1, // 在自适应缩放上乘算
"translate": { "x": 0, "y": 0 }, // 像素偏移
"motions": { "idle": "Idle" }, // 可选;插件主要用模型自带列表
"expressions": { "idle": "脸红" },
// 可选;模型自己表达不了的「作者意图」,见《动作语义》
"motionOptions": {
"OpenCase": { "hold": true },
"Selfie": { "prepend": "OpenCase" },
"SprayWater": { "preset": { "jingyu": 1 } }
}
}
}
motionOptions 的三个键都可以组合;不写就是默认行为(播一次然后回待机)。prepend 的前置动作同样受该动作自己的 motionOptions 约束。
catalog.json(可选,只影响显示名):
{
"motions": [{ "key": "Hammer", "label": "重锤出击", "category": "action" }],
"expressions": [{ "key": "脸红", "label": "脸红", "category": "emotion" }]
}
动作和表情列表以 c_0120.model3.json 里声明的为准,插件启动时从模型读出,所以换模型 / 改模型文件立刻生效,不用改插件代码。
宿主 HTTP 接口
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/live2d-pet/catalog |
已安装宠物 + 各自的动作/表情清单 + 运行时 URL |
| GET | /api/live2d-pet/asset/<id>/<path> |
只服务 model3.json 引用闭包内的文件(白名单 Set 比对 + realpath 包含,.. 永远匹配不上) |
| GET | /api/live2d-pet/runtime/live2dcubismcore.min.js |
用户自备的 Cubism Core |
| GET | /api/live2d-pet/runtime/live2d-vendor.js |
插件内置的 MIT vendor 分包(pixi.js + 引擎),按需懒加载 |
API 与资产路由默认只答本机回环请求。
架构
dsh-live2d-pet/
package.json dsh.client.platform = web -> 双半区包
cordis.patch.yml bundle patch:插一行 live2d-pet
lib/
index.js 宿主半区:宠物发现 / 引用闭包资产路由 / 运行时分发
client.js 浏览器半区:手写 __ModuleLoader__ 工厂,无构建步骤
live2d-vendor.js pixi.js + untitled-pixi-live2d-engine 的 IIFE(esbuild 产物)
src/vendor-entry.ts vendor 分包入口(npm run build:vendor 重新生成)
Vendor 分包懒加载:只有真正挂载 Live2D 宠物时才注入 live2d-vendor.js,页面首屏不为它买单。
二次开发
npm install # pixi.js / untitled-pixi-live2d-engine / esbuild
npm run build:vendor # 重新生成 lib/live2d-vendor.js
改完 lib/client.js 后重启 dsh web(bundle 不做热重载)。
回归测试在仓库的 tools/browser-test/:无头 Edge + CDP,在真实 WebGL 里跑完整契约。
cd ../../tools/browser-test && npm install && npm run suite
许可
插件代码:MIT
vendor 分包:pixi.js(MIT)+ untitled-pixi-live2d-engine(MIT),可随包分发
Cubism Core:Live2D 专有,用户自备,本插件不内置
DS鲸鱼娘模型:CC BY-NC-SA 4.0(署名 · 非商业 · 相同方式共享),见
pets/ds-whale-girl/LICENSE。版权链:上善无形(鲸鱼娘角色原作,原创 OC「溟月」)→ ZipZipPipe(DeepSeek 女仆二创) → 氵六青(本模型)。氵六青已授权本项目转载开源,但该授权不解除基础版权, 所以 NC / SA 依然有效;商业使用需分别取得三人授权。
完整说明见
../NOTICE.md。
链接
同类插件
zhu1090093659/dsh-web-ui#packages/dsh-pet★ 7869
常驻界面的鲸鱼娘:随智能体状态切换动画,可摸头互动、喂小鱼干养亲密度,从幼鲸一路养成。
PC2005-cloud/dsh-pet#dsh-pet★ 698
DSH Web UI 桌面宠物:25 个透明动画、屏幕漫游、点击反应与拖拽,附可复现的素材生成链。
Nagi-ovo/dsh-ads★ 630
2005 年中文站点风格的整活广告插件:侧栏广告/信息流/角落弹窗 + 假关闭叉,素材全虚构。
vlln/whale-girl★ 328
桌面宠物(QQ 宠物形态):右下角悬浮、可拖拽/投喂/玩耍。
yyh-001/dsh-meme★ 97
聊天表情包:纯文本斗图、情绪主动发图、像 QQ/微信 一样发图、AI 自动学图、自定义表情包。
a86582751/dsh-nexttavern★ 91
面向 DeepSeek Harness 的角色扮演工作台:人物卡可以导入(SillyTavern/TauriTavern),也可以从零交互式写出一张;长篇 TXT 能改编成可玩的角色卡,精读或粗颗粒度两种读法;主代理按剧情主动查阅世界书,正文在独立的酒馆阅读 TAB 中呈现;同一对话里探索多条世界线(重新生成、改后发送、显式分支);带作用域的文风预设(16 种自带文风)、角色 Agent 集群、独立决策卡,以及角色卡或小说导出;长篇由固定设定前缀、硬切上下文窗口、可追溯导演笔记与关键词/语义/混合检索撑住,嵌入可用在线服务,也可完全本地运行。面向 Harness 0.1.2-alpha.3,安装需按文档应用显式兼容补丁。
社区评论
评论公开保存在 GitHub Discussions。加载评论会连接 GitHub 和 Giscus;发表内容需要 GitHub 账号。