mocode-pet-app 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/assets/pets/01-robo-cat.motion.css +42 -0
  2. package/assets/pets/01-robo-cat.svg +2 -2
  3. package/assets/pets/02-robo-dog.motion.css +42 -0
  4. package/assets/pets/02-robo-dog.svg +1 -1
  5. package/assets/pets/03-robo-fox.svg +1 -1
  6. package/assets/pets/04-robo-panda.svg +1 -1
  7. package/assets/pets/05-robo-owl.svg +3 -3
  8. package/assets/pets/06-robo-bunny.svg +2 -2
  9. package/assets/pets/07-robo-frog.svg +3 -3
  10. package/assets/pets/09-robo-penguin.svg +3 -3
  11. package/assets/pets/10-robo-dino.svg +3 -3
  12. package/assets/pets/11-slime-blob.motion.css +44 -0
  13. package/assets/pets/11-slime-blob.svg +3 -3
  14. package/assets/pets/12-ghost-byte.svg +3 -3
  15. package/assets/pets/13-cactus-bot.svg +2 -2
  16. package/assets/pets/15-satellite-bot.svg +1 -1
  17. package/assets/pets/16-jellyfish-bot.svg +3 -3
  18. package/assets/pets/18-star-bot.svg +3 -3
  19. package/assets/pets/manifest.json +20 -3
  20. package/dist/assets/pets/01-robo-cat.motion.css +42 -0
  21. package/dist/assets/pets/01-robo-cat.svg +2 -2
  22. package/dist/assets/pets/02-robo-dog.motion.css +42 -0
  23. package/dist/assets/pets/02-robo-dog.svg +1 -1
  24. package/dist/assets/pets/03-robo-fox.svg +1 -1
  25. package/dist/assets/pets/04-robo-panda.svg +1 -1
  26. package/dist/assets/pets/05-robo-owl.svg +3 -3
  27. package/dist/assets/pets/06-robo-bunny.svg +2 -2
  28. package/dist/assets/pets/07-robo-frog.svg +3 -3
  29. package/dist/assets/pets/09-robo-penguin.svg +3 -3
  30. package/dist/assets/pets/10-robo-dino.svg +3 -3
  31. package/dist/assets/pets/11-slime-blob.motion.css +44 -0
  32. package/dist/assets/pets/11-slime-blob.svg +3 -3
  33. package/dist/assets/pets/12-ghost-byte.svg +3 -3
  34. package/dist/assets/pets/13-cactus-bot.svg +2 -2
  35. package/dist/assets/pets/15-satellite-bot.svg +1 -1
  36. package/dist/assets/pets/16-jellyfish-bot.svg +3 -3
  37. package/dist/assets/pets/18-star-bot.svg +3 -3
  38. package/dist/assets/pets/manifest.json +20 -3
  39. package/dist/assets/tray-icon.png +0 -0
  40. package/dist/e2e-mood-check.js +128 -0
  41. package/dist/main.js +49 -2
  42. package/dist/mood-current-state.test.js +55 -0
  43. package/dist/mood-determinism.pbt.js +66 -0
  44. package/dist/mood-tracker-timing.test.js +70 -0
  45. package/dist/mood-tracker.js +41 -0
  46. package/dist/mood-tracker.test.js +56 -0
  47. package/dist/mood.js +102 -0
  48. package/dist/mood.pbt.js +52 -0
  49. package/dist/quips.js +45 -0
  50. package/dist/quips.test.js +57 -0
  51. package/dist/renderer/dom-mood.js +53 -0
  52. package/dist/renderer/dom-mood.test.js +105 -0
  53. package/dist/renderer/index.html +2 -0
  54. package/dist/renderer/preload.js +9 -2
  55. package/dist/renderer/renderer.js +25 -9
  56. package/dist/renderer/style.css +89 -0
  57. package/dist/skins.js +32 -1
  58. package/dist/skins.test.js +73 -0
  59. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  // 渲染进程:纯展示逻辑,不含业务判断。接收 preload 暴露的 petBridge.onState 回调,
2
2
  // 按 PetState 切换外层容器的 CSS class,驱动 style.css 里定义的 @keyframes 动画。
3
3
  // mascot.svg 本身的呼吸灯/眨眼动画(inline <animate>)始终运行,不受这里的 class 切换影响。
4
+ import { applyMood, applySkinMotion, createBubbleController, QUIP_VISIBLE_MS } from './dom-mood.js';
4
5
  const ALL_STATE_CLASSES = [
5
6
  'pet-idle',
6
7
  'pet-thinking',
@@ -26,6 +27,13 @@ function applyState(container, state) {
26
27
  container.classList.remove(...ALL_STATE_CLASSES);
27
28
  container.classList.add(STATE_CLASS[state] ?? 'pet-idle');
28
29
  }
30
+ /** 从真实 `#pet-skin-motion` <link> 取值后交给抽取出的纯函数 applySkinMotion(见 dom-mood.ts)处理。 */
31
+ function setSkinMotion(motionFile) {
32
+ const link = document.getElementById('pet-skin-motion');
33
+ if (!link)
34
+ return;
35
+ applySkinMotion(link, motionFile);
36
+ }
29
37
  /** 把 assets/ 下的 SVG 文本 inline 插入指定容器(保留内部 id,供 CSS 选择器跨状态切换样式)。 */
30
38
  async function inlineSvgInto(container, assetPath) {
31
39
  try {
@@ -38,10 +46,12 @@ async function inlineSvgInto(container, assetPath) {
38
46
  // 素材加载失败:容器保持空白,不影响状态机/IPC 正常工作(降级为无形象但仍可运行)
39
47
  }
40
48
  }
41
- /** 切换皮肤:清空宠物容器后重新 inline 新素材(信号灯/状态 class 均不受影响,独立于宠物素材本身)。 */
42
- async function swapSkin(petContainer, assetPath) {
49
+ /** 切换皮肤:清空宠物容器后重新 inline 新素材(信号灯/状态 class 均不受影响,独立于宠物素材本身),
50
+ * 同时设置/清空个性化动作覆盖 CSS( applySkinMotion) */
51
+ async function swapSkin(petContainer, assetPath, motionFile) {
43
52
  petContainer.innerHTML = '';
44
53
  await inlineSvgInto(petContainer, assetPath);
54
+ setSkinMotion(motionFile);
45
55
  }
46
56
  window.addEventListener('DOMContentLoaded', async () => {
47
57
  // 状态 class 加在 #pet-stage 上(mascot 与信号灯共同的祖先容器),两者的动画/灯光规则
@@ -49,21 +59,28 @@ window.addEventListener('DOMContentLoaded', async () => {
49
59
  const stage = document.getElementById('pet-stage');
50
60
  const petContainer = document.getElementById('pet-container');
51
61
  const lampContainer = document.getElementById('signal-light-container');
52
- if (!stage || !petContainer || !lampContainer)
62
+ const bubbleEl = document.getElementById('pet-bubble');
63
+ if (!stage || !petContainer || !lampContainer || !bubbleEl)
53
64
  return;
54
65
  applyState(stage, 'idle');
55
66
  // 启动时的初始皮肤:主动向主进程 invoke 拉取(而非等主进程 send 推送)——
56
67
  // 消除 did-finish-load 推送与本文件异步注册监听器之间的时序竞争(见 main.ts pushSkinToRenderer 注释)。
57
- const initialAssetPath = await window.petBridge
68
+ const initialSkin = await window.petBridge
58
69
  .getInitialSkin()
59
- .then((r) => r.assetPath)
60
- .catch(() => '../assets/mascot.svg');
70
+ .catch(() => ({ assetPath: '../assets/mascot.svg', motionFile: undefined }));
61
71
  await Promise.all([
62
- inlineSvgInto(petContainer, initialAssetPath),
72
+ inlineSvgInto(petContainer, initialSkin.assetPath),
63
73
  inlineSvgInto(lampContainer, '../assets/signal-light.svg'),
64
74
  ]);
75
+ setSkinMotion(initialSkin.motionFile);
76
+ const bubbleController = createBubbleController();
65
77
  window.petBridge.onState((state) => applyState(stage, state));
66
- window.petBridge.onSkin((assetPath) => swapSkin(petContainer, assetPath));
78
+ window.petBridge.onSkin(({ assetPath, motionFile }) => swapSkin(petContainer, assetPath, motionFile));
79
+ window.petBridge.onMood((mood, quip) => {
80
+ applyMood(stage, mood);
81
+ if (mood !== null && quip)
82
+ bubbleController.showQuip(bubbleEl, quip, QUIP_VISIBLE_MS);
83
+ });
67
84
  // 拖拽放置:窗口默认鼠标穿透(见 main.ts setIgnoreMouseEvents(true,{forward:true})),
68
85
  // 鼠标悬停到宠物身上时取消穿透(可交互),离开后恢复穿透(不遮挡桌面下层点击)。这是 Electron
69
86
  // 官方推荐的 hover 点击穿透模式
@@ -107,4 +124,3 @@ window.addEventListener('DOMContentLoaded', async () => {
107
124
  window.petBridge.dragEnd();
108
125
  });
109
126
  });
110
- export {};
@@ -18,6 +18,7 @@ html, body {
18
18
  }
19
19
 
20
20
  #pet-stage {
21
+ position: relative; /* #pet-bubble 用 absolute 定位在宠物上方,需要相对此容器而非更外层元素 */
21
22
  width: 100%;
22
23
  height: 100%;
23
24
  display: flex;
@@ -27,6 +28,33 @@ html, body {
27
28
  justify-content: center;
28
29
  }
29
30
 
31
+ /* ── mood 气泡:展示 pickQuip 选出的文案,淡入/淡出不影响 mood-<kind> class(见 renderer.ts showQuip)。 ── */
32
+ #pet-bubble {
33
+ position: absolute;
34
+ top: 4px;
35
+ left: 50%;
36
+ transform: translateX(-50%);
37
+ max-width: 90%;
38
+ padding: 4px 10px;
39
+ border-radius: 10px;
40
+ background: #0d1b2a;
41
+ color: #2afadf;
42
+ font-family: sans-serif;
43
+ font-size: 12px;
44
+ line-height: 1.4;
45
+ text-align: center;
46
+ white-space: nowrap;
47
+ overflow: hidden;
48
+ text-overflow: ellipsis;
49
+ pointer-events: none;
50
+ opacity: 0;
51
+ transition: opacity 0.3s;
52
+ }
53
+
54
+ #pet-bubble.pet-bubble-visible {
55
+ opacity: 1;
56
+ }
57
+
30
58
  #pet-container {
31
59
  flex: 1 1 auto;
32
60
  height: 100%;
@@ -234,3 +262,64 @@ html, body {
234
262
  .pet-waiting-human #pet-lamp-red {
235
263
  animation: lamp-blink-fast 0.3s ease-in-out infinite;
236
264
  }
265
+
266
+ /* ── mood(衍生情绪)演出:与 state class 独立叠加,选择器单独书写、不与 state class 组合
267
+ * (design.md "情绪 → 演出的驱动":每种 mood 只共存于其判定所限定的 1-2 个 state,独立规则足够)。
268
+ * 没有对应 id(#pet-body-group/#pet-arm-left/#pet-arm-right 等)的宠物自然跳过,优雅降级。 ── */
269
+
270
+ /* ── mood-tired:机身缓慢下沉,浮动曲线比 idle 更慢更沉,模拟疲惫感 ── */
271
+ @keyframes pet-mood-tired-sink {
272
+ 0%, 100% { transform: translateY(0); opacity: 1; }
273
+ 50% { transform: translateY(4px); opacity: 0.85; }
274
+ }
275
+ .mood-tired #pet-body-group {
276
+ animation: pet-mood-tired-sink 4s ease-in-out infinite;
277
+ }
278
+
279
+ /* ── mood-bored:轻微左右摇晃,模拟东张西望 ── */
280
+ @keyframes pet-mood-bored-sway {
281
+ 0%, 100% { transform: rotate(0deg); }
282
+ 50% { transform: rotate(6deg); }
283
+ }
284
+ .mood-bored #pet-body-group {
285
+ animation: pet-mood-bored-sway 2.5s ease-in-out infinite;
286
+ transform-origin: 128px 125px;
287
+ }
288
+
289
+ /* ── mood-urging:比 thinking 更急促的摆动 ── */
290
+ @keyframes pet-mood-urging-nod {
291
+ 0%, 100% { transform: rotate(0deg); }
292
+ 50% { transform: rotate(-8deg); }
293
+ }
294
+ .mood-urging #pet-body-group {
295
+ animation: pet-mood-urging-nod 0.5s ease-in-out infinite;
296
+ transform-origin: 128px 125px;
297
+ }
298
+
299
+ /* ── mood-frustrated:比 error 更强烈的抖动 ── */
300
+ @keyframes pet-mood-frustrated-shake {
301
+ 0%, 100% { transform: translateX(0); }
302
+ 25% { transform: translateX(-10px); }
303
+ 75% { transform: translateX(10px); }
304
+ }
305
+ .mood-frustrated #pet-body-group {
306
+ animation: pet-mood-frustrated-shake 0.15s ease-in-out infinite;
307
+ }
308
+
309
+ /* ── mood-flustered:比 tool_call 更快速的双臂摆动 ── */
310
+ @keyframes pet-mood-flustered-arm-left {
311
+ 0%, 100% { transform: rotate(0deg); }
312
+ 50% { transform: rotate(-18deg); }
313
+ }
314
+ @keyframes pet-mood-flustered-arm-right {
315
+ 0%, 100% { transform: rotate(0deg); }
316
+ 50% { transform: rotate(18deg); }
317
+ }
318
+ .mood-flustered #pet-arm-left {
319
+ animation: pet-mood-flustered-arm-left 0.25s ease-in-out infinite;
320
+ transform-origin: 34px 128px;
321
+ }
322
+ .mood-flustered #pet-arm-right {
323
+ animation: pet-mood-flustered-arm-right 0.25s ease-in-out infinite 0.12s;
324
+ transform-origin: 222px 128px;
325
+ }
package/dist/skins.js CHANGED
@@ -10,6 +10,31 @@ let cached = null;
10
10
  export function skinsDir() {
11
11
  return path.join(__dirname, 'assets', 'pets');
12
12
  }
13
+ /** 从 manifest 里单个 pet 条目对象中,宽容解析出 motionFile/quips 两个可选字段。
14
+ * 纯函数,不涉及文件 I/O,便于单元测试覆盖各种非法输入的容错行为。
15
+ * - motionFile:必须是 string,否则不设置该字段。
16
+ * - quips:必须是非 null 对象;逐个 key 校验 value 是否为 string[],非法 key 被跳过;
17
+ * 若最终没有任何合法 key,则不设置 quips 字段。 */
18
+ export function parseSkinEntryExtras(pp) {
19
+ const result = {};
20
+ if (typeof pp.motionFile === 'string') {
21
+ result.motionFile = pp.motionFile;
22
+ }
23
+ if (typeof pp.quips === 'object' && pp.quips !== null) {
24
+ const rawQuips = pp.quips;
25
+ const quips = {};
26
+ for (const key of Object.keys(rawQuips)) {
27
+ const value = rawQuips[key];
28
+ if (Array.isArray(value) && value.every((v) => typeof v === 'string')) {
29
+ quips[key] = value;
30
+ }
31
+ }
32
+ if (Object.keys(quips).length > 0) {
33
+ result.quips = quips;
34
+ }
35
+ }
36
+ return result;
37
+ }
13
38
  /** 读取 manifest.json 里的候选皮肤列表(带内存缓存,manifest 在运行期不会变化)。 */
14
39
  export function listSkinEntries() {
15
40
  if (cached)
@@ -33,7 +58,13 @@ export function listSkinEntries() {
33
58
  continue;
34
59
  const pp = p;
35
60
  if (typeof pp.id === 'string' && typeof pp.file === 'string' && typeof pp.name === 'string') {
36
- entries.push({ id: pp.id, file: pp.file, name: pp.name });
61
+ const entry = { id: pp.id, file: pp.file, name: pp.name };
62
+ const extras = parseSkinEntryExtras(pp);
63
+ if (extras.motionFile !== undefined)
64
+ entry.motionFile = extras.motionFile;
65
+ if (extras.quips !== undefined)
66
+ entry.quips = extras.quips;
67
+ entries.push(entry);
37
68
  }
38
69
  }
39
70
  cached = entries;
@@ -0,0 +1,73 @@
1
+ // 单测 parseSkinEntryExtras:对 manifest 单个 pet 条目里 motionFile/quips 字段的宽容解析。
2
+ // 纯函数、无文件 I/O,覆盖各种合法/非法输入的容错行为。
3
+ // 运行:npx tsx packages/pet-app/src/skins.test.ts
4
+ import { parseSkinEntryExtras } from './skins.js';
5
+ let passed = 0;
6
+ const assert = (cond, msg) => {
7
+ if (!cond) {
8
+ console.error('✗ ' + msg);
9
+ process.exit(1);
10
+ }
11
+ passed++;
12
+ console.log('✓ ' + msg);
13
+ };
14
+ // 1) motionFile 是字符串 → 保留
15
+ {
16
+ const r = parseSkinEntryExtras({ motionFile: 'a.svg' });
17
+ assert(r.motionFile === 'a.svg', 'motionFile 为字符串时被保留');
18
+ }
19
+ // 2) motionFile 是非字符串类型(数字/布尔/对象)→ 不设置
20
+ {
21
+ const r1 = parseSkinEntryExtras({ motionFile: 123 });
22
+ const r2 = parseSkinEntryExtras({ motionFile: true });
23
+ const r3 = parseSkinEntryExtras({ motionFile: { foo: 'bar' } });
24
+ assert(r1.motionFile === undefined, 'motionFile 为数字时不设置该字段');
25
+ assert(r2.motionFile === undefined, 'motionFile 为布尔时不设置该字段');
26
+ assert(r3.motionFile === undefined, 'motionFile 为对象时不设置该字段');
27
+ }
28
+ // 3) motionFile 缺失 → undefined
29
+ {
30
+ const r = parseSkinEntryExtras({});
31
+ assert(r.motionFile === undefined, 'motionFile 缺失时为 undefined');
32
+ }
33
+ // 4) quips 是合法对象 → 保留
34
+ {
35
+ const r = parseSkinEntryExtras({ quips: { tired: ['a', 'b'], bored: ['c'] } });
36
+ assert(JSON.stringify(r.quips) === JSON.stringify({ tired: ['a', 'b'], bored: ['c'] }), 'quips 为合法对象时按原样保留');
37
+ }
38
+ // 5) quips 不是对象(字符串/数字/数组)→ 不设置
39
+ {
40
+ const r1 = parseSkinEntryExtras({ quips: 'nope' });
41
+ const r2 = parseSkinEntryExtras({ quips: 123 });
42
+ const r3 = parseSkinEntryExtras({ quips: ['a', 'b'] });
43
+ // 注意:数组本身 typeof 是 'object' 且不为 null,但其 key 均非法(index 的 value 不是 string[]),
44
+ // 所以最终 quips 里没有任何合法 key,因而 result.quips 应为 undefined。
45
+ assert(r1.quips === undefined, 'quips 为字符串时不设置该字段');
46
+ assert(r2.quips === undefined, 'quips 为数字时不设置该字段');
47
+ assert(r3.quips === undefined, 'quips 为数组(无合法 key)时不设置该字段');
48
+ }
49
+ // 6) quips 对象里某个 key 的 value 不是字符串数组 → 该 key 被跳过,其余合法 key 保留
50
+ {
51
+ const r = parseSkinEntryExtras({ quips: { tired: 'not an array', bored: ['ok'] } });
52
+ assert(r.quips !== undefined, 'quips 存在合法 key 时应设置该字段');
53
+ assert(r.quips.tired === undefined, '非法 key(tired)被跳过');
54
+ assert(JSON.stringify(r.quips.bored) === JSON.stringify(['ok']), '合法 key(bored)被保留');
55
+ }
56
+ // 7) quips 数组混有非字符串元素 → 该 key 整体被跳过
57
+ {
58
+ const r = parseSkinEntryExtras({ quips: { tired: ['ok', 123] } });
59
+ assert(r.quips === undefined, '数组元素含非字符串时,该 key 被跳过,quips 无合法 key 因而为 undefined');
60
+ }
61
+ // 8) quips 是空对象 → 不设置
62
+ {
63
+ const r = parseSkinEntryExtras({ quips: {} });
64
+ assert(r.quips === undefined, 'quips 为空对象(无合法 key)时不设置该字段');
65
+ }
66
+ // 9) motionFile/quips 都缺失 → 正常返回 {}
67
+ {
68
+ const r = parseSkinEntryExtras({ id: 'x', file: 'x.svg', name: 'X' });
69
+ assert(r.motionFile === undefined, '其余字段解析不受影响时,motionFile 仍为 undefined');
70
+ assert(r.quips === undefined, '其余字段解析不受影响时,quips 仍为 undefined');
71
+ assert(Object.keys(r).length === 0, '两字段皆缺失时返回空对象 {}');
72
+ }
73
+ console.log(`\nOK: ${passed} passed, 0 failed`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-pet-app",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "mocode 桌宠:独立 Electron 悬浮窗,展示 agent 执行状态动画。作为 mocode-ai 的可选子包,不随主包安装强制拉取。",
5
5
  "private": false,
6
6
  "type": "module",