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.
- package/assets/pets/01-robo-cat.motion.css +42 -0
- package/assets/pets/01-robo-cat.svg +2 -2
- package/assets/pets/02-robo-dog.motion.css +42 -0
- package/assets/pets/02-robo-dog.svg +1 -1
- package/assets/pets/03-robo-fox.svg +1 -1
- package/assets/pets/04-robo-panda.svg +1 -1
- package/assets/pets/05-robo-owl.svg +3 -3
- package/assets/pets/06-robo-bunny.svg +2 -2
- package/assets/pets/07-robo-frog.svg +3 -3
- package/assets/pets/09-robo-penguin.svg +3 -3
- package/assets/pets/10-robo-dino.svg +3 -3
- package/assets/pets/11-slime-blob.motion.css +44 -0
- package/assets/pets/11-slime-blob.svg +3 -3
- package/assets/pets/12-ghost-byte.svg +3 -3
- package/assets/pets/13-cactus-bot.svg +2 -2
- package/assets/pets/15-satellite-bot.svg +1 -1
- package/assets/pets/16-jellyfish-bot.svg +3 -3
- package/assets/pets/18-star-bot.svg +3 -3
- package/assets/pets/manifest.json +20 -3
- package/dist/assets/pets/01-robo-cat.motion.css +42 -0
- package/dist/assets/pets/01-robo-cat.svg +2 -2
- package/dist/assets/pets/02-robo-dog.motion.css +42 -0
- package/dist/assets/pets/02-robo-dog.svg +1 -1
- package/dist/assets/pets/03-robo-fox.svg +1 -1
- package/dist/assets/pets/04-robo-panda.svg +1 -1
- package/dist/assets/pets/05-robo-owl.svg +3 -3
- package/dist/assets/pets/06-robo-bunny.svg +2 -2
- package/dist/assets/pets/07-robo-frog.svg +3 -3
- package/dist/assets/pets/09-robo-penguin.svg +3 -3
- package/dist/assets/pets/10-robo-dino.svg +3 -3
- package/dist/assets/pets/11-slime-blob.motion.css +44 -0
- package/dist/assets/pets/11-slime-blob.svg +3 -3
- package/dist/assets/pets/12-ghost-byte.svg +3 -3
- package/dist/assets/pets/13-cactus-bot.svg +2 -2
- package/dist/assets/pets/15-satellite-bot.svg +1 -1
- package/dist/assets/pets/16-jellyfish-bot.svg +3 -3
- package/dist/assets/pets/18-star-bot.svg +3 -3
- package/dist/assets/pets/manifest.json +20 -3
- package/dist/assets/tray-icon.png +0 -0
- package/dist/e2e-mood-check.js +128 -0
- package/dist/main.js +49 -2
- package/dist/mood-current-state.test.js +55 -0
- package/dist/mood-determinism.pbt.js +66 -0
- package/dist/mood-tracker-timing.test.js +70 -0
- package/dist/mood-tracker.js +41 -0
- package/dist/mood-tracker.test.js +56 -0
- package/dist/mood.js +102 -0
- package/dist/mood.pbt.js +52 -0
- package/dist/quips.js +45 -0
- package/dist/quips.test.js +57 -0
- package/dist/renderer/dom-mood.js +53 -0
- package/dist/renderer/dom-mood.test.js +105 -0
- package/dist/renderer/index.html +2 -0
- package/dist/renderer/preload.js +9 -2
- package/dist/renderer/renderer.js +25 -9
- package/dist/renderer/style.css +89 -0
- package/dist/skins.js +32 -1
- package/dist/skins.test.js +73 -0
- package/package.json +1 -1
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// createMoodTracker().evaluate() 的"立即求值"与"定时刷新文案"时序单测。
|
|
2
|
+
// 覆盖 mood-tracker.ts 的四条分支(见该文件 evaluate 内部逻辑):
|
|
3
|
+
// 1) mood 从 null → 非 null(“进入”新 mood):立即返回 {mood, quip}(不等 ticker)。
|
|
4
|
+
// 2) mood 不变且非 null,未到 QUIP_REFRESH_MS(15000ms):返回 null(不重复推送)。
|
|
5
|
+
// 3) mood 不变且非 null,达到 QUIP_REFRESH_MS(>=):返回新的 {mood(不变), quip(新文案)}。
|
|
6
|
+
// 4) mood 从非 null → null(“退出”mood):立即返回 {mood: null}(不等 ticker);
|
|
7
|
+
// 之后 mood 仍为 null 与上次一致 → 直接 return null(mood 为 null 时不进入文案刷新判断分支)。
|
|
8
|
+
// pickQuip 内部用 Math.random() 挑文案,内容不可预测,只断言"是否有新结果"与 mood/quip 类型。
|
|
9
|
+
// 运行:npx tsx packages/pet-app/src/mood-tracker-timing.test.ts
|
|
10
|
+
import { createMoodTracker } from './mood-tracker.js';
|
|
11
|
+
let passed = 0;
|
|
12
|
+
const assert = (cond, msg) => {
|
|
13
|
+
if (!cond) {
|
|
14
|
+
console.error('✗ ' + msg);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
passed++;
|
|
18
|
+
console.log('✓ ' + msg);
|
|
19
|
+
};
|
|
20
|
+
// ── 场景1:mood “进入”立即产生推送(不必等 ticker)───────────────────────
|
|
21
|
+
// 注:flustered 判定是"滑动窗口"(见 mood.ts countInWindow,toolWindowMs 默认 10000ms),
|
|
22
|
+
// 不是"状态持续不变"型判定(那类是 tired/urging/bored,用 currentStateSince,天然会随时间推移
|
|
23
|
+
// 一直保持成立)。若只记 5 条 ts=0..4000 的 tool_call,窗口会在 now 推进过程中把早期事件滑出去
|
|
24
|
+
// (例如 now=10001 时窗口 [1,10001] 只剩 4 条,已跌破阈值 5,mood 会提前变回 null,
|
|
25
|
+
// 与场景2/3"mood 在 4000~19001 期间应保持 flustered 不变"的前提矛盾)。
|
|
26
|
+
// 因此这里持续每隔 1000ms 记一条 tool_call 直到 ts=19000,以确保 flustered 的滑动窗口在
|
|
27
|
+
// 整个测试所需的时间跨度内始终 ≥5 条,从而让 mood 保持不变、只测"文案刷新"这一件事。
|
|
28
|
+
const tracker = createMoodTracker();
|
|
29
|
+
for (let t = 0; t <= 19000; t += 1000) {
|
|
30
|
+
tracker.recordState('tool_call', t);
|
|
31
|
+
}
|
|
32
|
+
{
|
|
33
|
+
const r = tracker.evaluate(4000);
|
|
34
|
+
assert(r !== null && r.mood === 'flustered' && typeof r.quip === 'string', '场景1: 5 次 tool_call(10s 窗口内达阈值)→ evaluate 立即返回 {mood: "flustered", quip: string}');
|
|
35
|
+
}
|
|
36
|
+
// ── 场景2:mood 保持不变,未到 QUIP_REFRESH_MS(15000ms)→ 应返回 null ──────
|
|
37
|
+
{
|
|
38
|
+
const r = tracker.evaluate(4001);
|
|
39
|
+
assert(r === null, '场景2a: mood 不变(仍 flustered),仅过 1ms(远小于 15000ms)→ evaluate 返回 null');
|
|
40
|
+
}
|
|
41
|
+
{
|
|
42
|
+
const r = tracker.evaluate(4000 + 15000 - 1); // 18999
|
|
43
|
+
assert(r === null, '场景2b: mood 不变,距上次推文案差 1ms 未到 15000ms 刷新阈值 → evaluate 返回 null');
|
|
44
|
+
}
|
|
45
|
+
// ── 场景3:mood 不变但达到 QUIP_REFRESH_MS(>=)→ 应重新推送新文案 ──────────
|
|
46
|
+
{
|
|
47
|
+
const r = tracker.evaluate(4000 + 15000); // 19000,刚好达到刷新阈值(>=)
|
|
48
|
+
assert(r !== null && r.mood === 'flustered' && typeof r.quip === 'string', '场景3a: mood 不变,刚好达到 15000ms 刷新阈值(>=)→ evaluate 返回新的 {mood: "flustered", quip: string}');
|
|
49
|
+
}
|
|
50
|
+
{
|
|
51
|
+
const r = tracker.evaluate(19001);
|
|
52
|
+
assert(r === null, '场景3b: 刚刚才刷新过文案,立即再 evaluate(仅过 1ms)→ 返回 null');
|
|
53
|
+
}
|
|
54
|
+
// ── 场景4:mood 消失时也应立即推送一次 {mood: null},不等定时器 ────────────
|
|
55
|
+
const tracker2 = createMoodTracker();
|
|
56
|
+
tracker2.recordState('error', 0);
|
|
57
|
+
tracker2.recordState('error', 100);
|
|
58
|
+
{
|
|
59
|
+
const r = tracker2.evaluate(100);
|
|
60
|
+
assert(r !== null && r.mood === 'frustrated', '场景4a: 60s 窗口内 2 次 error(达阈值)→ evaluate 返回 {mood: "frustrated", ...}');
|
|
61
|
+
}
|
|
62
|
+
{
|
|
63
|
+
const r = tracker2.evaluate(100 + 60001); // 两次 error 事件均已滑出 60000ms 窗口
|
|
64
|
+
assert(r !== null && r.mood === null, '场景4b: error 事件滑出窗口,mood 从 frustrated 变为 null → evaluate 立即返回 {mood: null}(不等定时器)');
|
|
65
|
+
}
|
|
66
|
+
{
|
|
67
|
+
const r = tracker2.evaluate(100 + 60002);
|
|
68
|
+
assert(r === null, '场景4c: mood 仍为 null(与上次推送一致)→ evaluate 直接返回 null(mood 为 null 时不检查文案刷新)');
|
|
69
|
+
}
|
|
70
|
+
console.log(`\nOK: ${passed} passed, 0 failed`);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Mood 求值调度器:维护"当前活跃连接生命周期内的 state 事件缓冲" + "上次推送结果",
|
|
2
|
+
// 提供 reset/recordState/evaluate 三个方法供 main.ts 在合适时机调用。
|
|
3
|
+
// 不 import electron——只依赖 mood.ts(纯函数判定)与 quips.ts(文案选择),
|
|
4
|
+
// 使这部分逻辑可以脱离真实 Electron 运行时被单元测试覆盖(main.ts 顶层的
|
|
5
|
+
// electron import 与模块加载期 ipcMain.on/handle 副作用会导致该文件无法在测试里被直接 import)。
|
|
6
|
+
import { deriveMood } from './mood.js';
|
|
7
|
+
import { pickQuip } from './quips.js';
|
|
8
|
+
/** 文案刷新间隔:mood 保持不变且非 null 时,每隔此时长重新挑一条新文案推送(design.md 默认假设)。 */
|
|
9
|
+
export const QUIP_REFRESH_MS = 15_000;
|
|
10
|
+
/**
|
|
11
|
+
* 创建一个独立的 MoodTracker 实例。用工厂函数而非模块级共享状态,
|
|
12
|
+
* 便于单测里每个用例创建互不干扰的实例。
|
|
13
|
+
*/
|
|
14
|
+
export function createMoodTracker() {
|
|
15
|
+
const events = [];
|
|
16
|
+
let lastPushedMood = null;
|
|
17
|
+
let lastQuipPushedAt = -Infinity;
|
|
18
|
+
function reset() {
|
|
19
|
+
events.length = 0;
|
|
20
|
+
}
|
|
21
|
+
function recordState(state, ts = Date.now()) {
|
|
22
|
+
events.push({ state, ts });
|
|
23
|
+
}
|
|
24
|
+
function evaluate(now = Date.now(), skinQuips) {
|
|
25
|
+
const mood = deriveMood(events, now);
|
|
26
|
+
if (mood !== lastPushedMood) {
|
|
27
|
+
lastPushedMood = mood;
|
|
28
|
+
if (mood === null) {
|
|
29
|
+
return { mood: null };
|
|
30
|
+
}
|
|
31
|
+
lastQuipPushedAt = now;
|
|
32
|
+
return { mood, quip: pickQuip(mood, skinQuips) };
|
|
33
|
+
}
|
|
34
|
+
if (mood !== null && now - lastQuipPushedAt >= QUIP_REFRESH_MS) {
|
|
35
|
+
lastQuipPushedAt = now;
|
|
36
|
+
return { mood, quip: pickQuip(mood, skinQuips) };
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return { reset, recordState, evaluate };
|
|
41
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// createMoodTracker() 的"连接切换/断开清空 events"单元测试。
|
|
2
|
+
// events 是模块闻包私有变量,测试无法直接读取,只能通过行为观察验证 reset() 是否真的
|
|
3
|
+
// 清空了内部事件缓冲——利用 deriveMood 里 tired/bored/urging/flustered 判定依赖
|
|
4
|
+
// "事件缓冲"这一特性:先喂够触发某个 mood 的事件,evaluate 确认命中,再 reset() 后
|
|
5
|
+
// 立即 evaluate(几乎同一时刻),若 mood 变回 null 则说明 events 已被真正清空
|
|
6
|
+
// (deriveMood 对空数组必然返回 null)。
|
|
7
|
+
//
|
|
8
|
+
// 覆盖 tasks.md 4.1:
|
|
9
|
+
// 场景1:新连接建立覆盖旧连接(main.ts onConnection 里的 reset() 时机)
|
|
10
|
+
// 场景2:活跃连接断开(main.ts onDisconnect 里的 reset() 时机)
|
|
11
|
+
// 场景3:reset 后重新 recordState 走出全新判定,不受 reset 前残留事件干扰
|
|
12
|
+
//
|
|
13
|
+
// 运行:npx tsx packages/pet-app/src/mood-tracker.test.ts
|
|
14
|
+
import { createMoodTracker } from './mood-tracker.js';
|
|
15
|
+
let passed = 0;
|
|
16
|
+
const assert = (cond, msg) => {
|
|
17
|
+
if (!cond) {
|
|
18
|
+
console.error('✗ ' + msg);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
passed++;
|
|
22
|
+
console.log('✓ ' + msg);
|
|
23
|
+
};
|
|
24
|
+
// ── 场景1:新连接建立覆盖旧连接 → reset() 清空 events ──────────────────────
|
|
25
|
+
{
|
|
26
|
+
const tracker = createMoodTracker();
|
|
27
|
+
// 10s 窗口(默认 toolWindowMs=10000)内连续 5 次 tool_call(默认 toolCount=5)→ flustered。
|
|
28
|
+
tracker.recordState('tool_call', 1000);
|
|
29
|
+
tracker.recordState('tool_call', 2000);
|
|
30
|
+
tracker.recordState('tool_call', 3000);
|
|
31
|
+
tracker.recordState('tool_call', 4000);
|
|
32
|
+
tracker.recordState('tool_call', 5000);
|
|
33
|
+
const r1 = tracker.evaluate(6000);
|
|
34
|
+
assert(r1 !== null && r1.mood === 'flustered', '场景1步骤1:10s 内 5 次 tool_call → evaluate 判定为 flustered');
|
|
35
|
+
tracker.reset();
|
|
36
|
+
// 几乎同一时刻再 evaluate,若 events 真被清空,deriveMood 对空数组必返回 null,
|
|
37
|
+
// 且上次推送是 'flustered' → null 属于变化,应推送 {mood: null}。
|
|
38
|
+
const r2 = tracker.evaluate(6001);
|
|
39
|
+
assert(r2 !== null && r2.mood === null, '场景1步骤2:reset() 后立即 evaluate → mood 变为 null(证明 events 已清空)');
|
|
40
|
+
// ── 场景3(复用场景1已 reset 过的 tracker):reset 后重新 recordState 走出全新判定 ──
|
|
41
|
+
tracker.recordState('waiting_human', 10000);
|
|
42
|
+
const r3 = tracker.evaluate(10000 + 30000); // 默认 urgingMs=30000
|
|
43
|
+
assert(r3 !== null && r3.mood === 'urging', '场景3:reset 后重新 recordState(waiting_human)→ evaluate 判定为 urging(不受 reset 前残留 tool_call 事件干扰)');
|
|
44
|
+
}
|
|
45
|
+
// ── 场景2:活跃连接断开 → reset() 清空 events ────────────────────────────
|
|
46
|
+
{
|
|
47
|
+
const tracker = createMoodTracker();
|
|
48
|
+
// idle 保持 300000ms(默认 boredMs=300000)→ bored。
|
|
49
|
+
tracker.recordState('idle', 0);
|
|
50
|
+
const r1 = tracker.evaluate(300_000);
|
|
51
|
+
assert(r1 !== null && r1.mood === 'bored', '场景2步骤1:idle 保持 boredMs(300000ms)→ evaluate 判定为 bored');
|
|
52
|
+
tracker.reset();
|
|
53
|
+
const r2 = tracker.evaluate(300_001);
|
|
54
|
+
assert(r2 !== null && r2.mood === null, '场景2步骤2:reset() 后立即 evaluate → mood 变为 null(证明 events 已清空,不残留 bored 判定)');
|
|
55
|
+
}
|
|
56
|
+
console.log(`\nOK: ${passed} passed, 0 failed`);
|
package/dist/mood.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// 桌宠 Mood(衍生情绪)引擎:纯函数,输入 state 事件历史 + 当前时间,输出当前应呈现的情绪。
|
|
2
|
+
// 设计意图(见 design.md "Mood 引擎"):
|
|
3
|
+
// - 不进入 WS 协议——完全是 Server(pet-app)侧基于已收到 state 消息的本地再加工,
|
|
4
|
+
// 不新增/修改任何 Client ↔ Server 消息类型,mocode 主包侧无需感知 Mood 概念。
|
|
5
|
+
// - 不改变 PetState 广播——mood 与 state 是叠加关系(渲染进程用独立的 mood-<kind> class),
|
|
6
|
+
// 本文件不修改、不影响现有 state class 切换逻辑。
|
|
7
|
+
// - 无状态纯函数:不持有任何模块级可变状态,不做 I/O,同输入必同输出,便于单测与 PBT。
|
|
8
|
+
/** 读取一个正数环境变量,非法/缺失时回退默认值(与 main.ts petPort() 的校验写法一致)。 */
|
|
9
|
+
function envNumber(name, defaultValue) {
|
|
10
|
+
const v = process.env[name];
|
|
11
|
+
const n = v ? Number(v) : NaN;
|
|
12
|
+
return Number.isFinite(n) && n > 0 ? n : defaultValue;
|
|
13
|
+
}
|
|
14
|
+
/** 默认阈值(均为假设,可通过环境变量覆盖,见 design.md 默认值表)。 */
|
|
15
|
+
export const DEFAULT_MOOD_THRESHOLDS = {
|
|
16
|
+
tiredMs: envNumber('MOCODE_PET_MOOD_TIRED_MS', 60_000),
|
|
17
|
+
boredMs: envNumber('MOCODE_PET_MOOD_BORED_MS', 300_000),
|
|
18
|
+
urgingMs: envNumber('MOCODE_PET_MOOD_URGING_MS', 30_000),
|
|
19
|
+
errorWindowMs: envNumber('MOCODE_PET_MOOD_ERROR_WINDOW_MS', 60_000),
|
|
20
|
+
errorCount: envNumber('MOCODE_PET_MOOD_ERROR_COUNT', 2),
|
|
21
|
+
toolWindowMs: envNumber('MOCODE_PET_MOOD_TOOL_WINDOW_MS', 10_000),
|
|
22
|
+
toolCount: envNumber('MOCODE_PET_MOOD_TOOL_COUNT', 5),
|
|
23
|
+
};
|
|
24
|
+
/** 固定优先级(高→低),同一时刻只呈现其中最高优先级的一种(Requirement 1.7)。 */
|
|
25
|
+
export const MOOD_PRIORITY = [
|
|
26
|
+
'frustrated',
|
|
27
|
+
'flustered',
|
|
28
|
+
'urging',
|
|
29
|
+
'tired',
|
|
30
|
+
'bored',
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* 辅助纯函数:找出"当前状态"及其"从何时开始保持不变"(用于 tired/bored/urging 的时长判定)。
|
|
34
|
+
* 后置条件:events 为空返回 null;否则返回 events 中最后一个事件的 state,
|
|
35
|
+
* 以及从末尾往前扫描、状态连续相同的最早一条事件的 ts。
|
|
36
|
+
*/
|
|
37
|
+
export function currentStateSince(events) {
|
|
38
|
+
if (events.length === 0)
|
|
39
|
+
return null;
|
|
40
|
+
const last = events[events.length - 1];
|
|
41
|
+
let since = last.ts;
|
|
42
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
43
|
+
if (events[i].state !== last.state)
|
|
44
|
+
break;
|
|
45
|
+
since = events[i].ts;
|
|
46
|
+
}
|
|
47
|
+
return { state: last.state, since };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 纯函数:给定完整事件历史(按到达顺序,ts 非降序)与当前时间,推导当前应呈现的 MoodKind。
|
|
51
|
+
* 前置条件:events 按 ts 非降序排列。
|
|
52
|
+
* 后置条件:
|
|
53
|
+
* - 返回值 ∈ MoodKind ∪ {null}。
|
|
54
|
+
* - 若返回非 null 的 kind K,则 MOOD_PRIORITY 中排在 K 之前的所有 kind 的判定条件均不成立
|
|
55
|
+
* (优先级排他性,Property 1)。
|
|
56
|
+
* 不依赖调用历史之外的隐藏状态,任意次调用同输入同输出(确定性,Property 2)。
|
|
57
|
+
*/
|
|
58
|
+
export function deriveMood(events, now, thresholds = DEFAULT_MOOD_THRESHOLDS) {
|
|
59
|
+
for (const kind of MOOD_PRIORITY) {
|
|
60
|
+
if (matchesMood(kind, events, now, thresholds))
|
|
61
|
+
return kind;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 按单个 MoodKind 的判定条件检查是否命中(见 design.md 判定逻辑表)。
|
|
67
|
+
* 导出供测试(PBT)交叉验证 deriveMood 的优先级排他性,不改变 deriveMood 既有公开行为。
|
|
68
|
+
*/
|
|
69
|
+
export function matchesMood(kind, events, now, thresholds) {
|
|
70
|
+
switch (kind) {
|
|
71
|
+
case 'frustrated':
|
|
72
|
+
return countInWindow(events, now, thresholds.errorWindowMs, 'error') >= thresholds.errorCount;
|
|
73
|
+
case 'flustered':
|
|
74
|
+
return countInWindow(events, now, thresholds.toolWindowMs, 'tool_call') >= thresholds.toolCount;
|
|
75
|
+
case 'urging': {
|
|
76
|
+
const cur = currentStateSince(events);
|
|
77
|
+
return cur !== null && cur.state === 'waiting_human' && now - cur.since >= thresholds.urgingMs;
|
|
78
|
+
}
|
|
79
|
+
case 'tired': {
|
|
80
|
+
const cur = currentStateSince(events);
|
|
81
|
+
return (cur !== null &&
|
|
82
|
+
(cur.state === 'thinking' || cur.state === 'tool_call') &&
|
|
83
|
+
now - cur.since >= thresholds.tiredMs);
|
|
84
|
+
}
|
|
85
|
+
case 'bored': {
|
|
86
|
+
const cur = currentStateSince(events);
|
|
87
|
+
return cur !== null && cur.state === 'idle' && now - cur.since >= thresholds.boredMs;
|
|
88
|
+
}
|
|
89
|
+
default:
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** 统计 events 中 ts ∈ [now - windowMs, now] 且 state 匹配的条数。 */
|
|
94
|
+
function countInWindow(events, now, windowMs, state) {
|
|
95
|
+
const from = now - windowMs;
|
|
96
|
+
let count = 0;
|
|
97
|
+
for (const e of events) {
|
|
98
|
+
if (e.state === state && e.ts >= from && e.ts <= now)
|
|
99
|
+
count++;
|
|
100
|
+
}
|
|
101
|
+
return count;
|
|
102
|
+
}
|
package/dist/mood.pbt.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// PBT(属性测试):deriveMood 的"Mood 优先级排他性"(spec: desktop-pet 任务 2.1,Property 1)。
|
|
2
|
+
// 性质:若 deriveMood(events, now) 返回非 null 的 kind K,则 MOOD_PRIORITY 中排在 K 之前的
|
|
3
|
+
// 所有 kind,用 matchesMood 单独判定都必须为 false——否则 deriveMood 就该返回那个更高优先级的
|
|
4
|
+
// kind 而不是 K(deriveMood 内部本就是按 MOOD_PRIORITY 顺序 first-match,这里做交叉验证)。
|
|
5
|
+
// 用 fast-check 生成任意 MoodEvent[](按 ts 排序)与 now,跑 500 次样本。
|
|
6
|
+
// 运行:npx tsx packages/pet-app/src/mood.pbt.ts
|
|
7
|
+
import fc from 'fast-check';
|
|
8
|
+
import { DEFAULT_MOOD_THRESHOLDS, MOOD_PRIORITY, deriveMood, matchesMood } from './mood.js';
|
|
9
|
+
let passed = 0;
|
|
10
|
+
const assert = (cond, msg) => {
|
|
11
|
+
if (!cond) {
|
|
12
|
+
console.error('✗ ' + msg);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
passed++;
|
|
16
|
+
};
|
|
17
|
+
const petStateArb = fc.constantFrom('idle', 'thinking', 'speaking', 'tool_call', 'done', 'aborted', 'error', 'waiting_human');
|
|
18
|
+
const moodEventArb = fc.record({
|
|
19
|
+
state: petStateArb,
|
|
20
|
+
ts: fc.nat({ max: 1_000_000 }),
|
|
21
|
+
});
|
|
22
|
+
// 生成 events(未排序)+ 一个 offset,now = 最后一条事件的 ts(排序后)+ offset。
|
|
23
|
+
// offset 范围覆盖负值(now 落在历史事件之前/之间)到较大正值(触发 tired/bored/urging 的时长判定),
|
|
24
|
+
// 以增加命中各优先级分支的概率。
|
|
25
|
+
const sampleArb = fc
|
|
26
|
+
.tuple(fc.array(moodEventArb, { maxLength: 30 }), fc.integer({ min: -1000, max: 500_000 }))
|
|
27
|
+
.map(([rawEvents, offset]) => {
|
|
28
|
+
const events = [...rawEvents].sort((a, b) => a.ts - b.ts);
|
|
29
|
+
const now = events.length > 0 ? events[events.length - 1].ts + offset : Math.max(0, offset);
|
|
30
|
+
return { events, now };
|
|
31
|
+
});
|
|
32
|
+
(async () => {
|
|
33
|
+
fc.assert(fc.property(sampleArb, ({ events, now }) => {
|
|
34
|
+
const result = deriveMood(events, now, DEFAULT_MOOD_THRESHOLDS);
|
|
35
|
+
if (result === null)
|
|
36
|
+
return true; // 全不命中,排他性天然满足
|
|
37
|
+
const idx = MOOD_PRIORITY.indexOf(result);
|
|
38
|
+
for (let i = 0; i < idx; i++) {
|
|
39
|
+
const higherKind = MOOD_PRIORITY[i];
|
|
40
|
+
if (matchesMood(higherKind, events, now, DEFAULT_MOOD_THRESHOLDS)) {
|
|
41
|
+
return false; // 排在 result 之前的 kind 也命中了 → 违反排他性
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}), { numRuns: 500 });
|
|
46
|
+
assert(true, 'Property 1 (Mood 优先级排他性):deriveMood 返回的 kind 之前无更高优先级命中(500 次样本)');
|
|
47
|
+
console.log(`\nOK: ${passed} passed, 0 failed`);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
})().catch((err) => {
|
|
50
|
+
console.error('✗ PBT 执行异常:', err);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
package/dist/quips.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// 通用文案池:各 mood 对应的中文吐槽短句,以及随机选取逻辑。
|
|
2
|
+
// 皮肤可提供自定义文案(skinQuips)覆盖某个 mood 的候选池;未覆盖或为空时回退到通用文案池。
|
|
3
|
+
/** 每种 mood 的通用吐槽短句候选池,语气轻松幽默(赛博终端风机器人设定)。 */
|
|
4
|
+
export const UNIVERSAL_QUIPS = {
|
|
5
|
+
frustrated: [
|
|
6
|
+
'又报错了…要不要歇一下?',
|
|
7
|
+
'这个 bug 有点顽固啊',
|
|
8
|
+
'报错第二次了,压力有点大',
|
|
9
|
+
'怎么又翻车了,深呼吸一下',
|
|
10
|
+
],
|
|
11
|
+
flustered: [
|
|
12
|
+
'感觉在同时干好多件事…',
|
|
13
|
+
'工具切换得好快!',
|
|
14
|
+
'有点跟不上节奏了',
|
|
15
|
+
'脑子有点转不过来了',
|
|
16
|
+
],
|
|
17
|
+
urging: [
|
|
18
|
+
'还在等你哦~',
|
|
19
|
+
'我等得有点无聊了',
|
|
20
|
+
'需要你看一下呢',
|
|
21
|
+
'别忘了我还在等呢',
|
|
22
|
+
],
|
|
23
|
+
tired: [
|
|
24
|
+
'这个任务有点久,好累…',
|
|
25
|
+
'还在跑,坐等一下',
|
|
26
|
+
'累了累了,继续肝',
|
|
27
|
+
'电量有点低了,撑住',
|
|
28
|
+
],
|
|
29
|
+
bored: [
|
|
30
|
+
'闲着没事,打个哈欠',
|
|
31
|
+
'东张西望中…',
|
|
32
|
+
'有什么新任务吗',
|
|
33
|
+
'好安静啊,有点无聊',
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* 根据 mood 选一条吐槽文案。
|
|
38
|
+
* 若 skinQuips 里该 mood 有非空候选列表,优先从中随机选;否则回退到 pool(默认通用文案池)。
|
|
39
|
+
* 纯函数,唯一的非确定性来源是 Math.random()。
|
|
40
|
+
*/
|
|
41
|
+
export function pickQuip(mood, skinQuips, pool = UNIVERSAL_QUIPS) {
|
|
42
|
+
const skinList = skinQuips?.[mood];
|
|
43
|
+
const list = skinList && skinList.length > 0 ? skinList : pool[mood];
|
|
44
|
+
return list[Math.floor(Math.random() * list.length)];
|
|
45
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// 单测:pickQuip 的回退逻辑(皮肤池覆盖 / 未覆盖 / 空数组 / 未传皮肤池)。
|
|
2
|
+
// 覆盖 5 种 MoodKind,并验证多次随机调用不会跑出候选池范围。
|
|
3
|
+
// 运行:npx tsx packages/pet-app/src/quips.test.ts
|
|
4
|
+
import { pickQuip, UNIVERSAL_QUIPS } from './quips.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
|
+
const ALL_MOODS = ['frustrated', 'flustered', 'urging', 'tired', 'bored'];
|
|
15
|
+
// ── 1) 皮肤池覆盖该 mood(单条)→ 必然返回皮肤池里的那条 ──────────────────
|
|
16
|
+
{
|
|
17
|
+
const skinQuips = { tired: ['猫猫要趴一下…'] };
|
|
18
|
+
const r = pickQuip('tired', skinQuips);
|
|
19
|
+
assert(r === '猫猫要趴一下…', `皮肤池覆盖 tired(单条)→ 返回皮肤池文案(实际:"${r}")`);
|
|
20
|
+
}
|
|
21
|
+
// ── 1b) 皮肤池覆盖该 mood(多条)→ 返回值必在皮肤池数组内 ─────────────────
|
|
22
|
+
{
|
|
23
|
+
const skinList = ['猫猫要趴一下…', '电量告急,先眯一会'];
|
|
24
|
+
const skinQuips = { tired: skinList };
|
|
25
|
+
for (let i = 0; i < 20; i++) {
|
|
26
|
+
const r = pickQuip('tired', skinQuips);
|
|
27
|
+
assert(skinList.includes(r), `皮肤池覆盖 tired(多条)→ 返回值 ∈ 皮肤池(实际:"${r}")`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// ── 2) 皮肤池未覆盖该 mood(skinQuips 里没有 tired 这个 key)→ 回退通用池 ──
|
|
31
|
+
{
|
|
32
|
+
const skinQuips = { bored: ['无所事事'] };
|
|
33
|
+
const r = pickQuip('tired', skinQuips);
|
|
34
|
+
assert(UNIVERSAL_QUIPS.tired.includes(r), `皮肤池未覆盖 tired → 回退 UNIVERSAL_QUIPS.tired(实际:"${r}")`);
|
|
35
|
+
}
|
|
36
|
+
// ── 3) 皮肤池对应 mood 是空数组 → 视为未覆盖,回退通用池 ─────────────────
|
|
37
|
+
{
|
|
38
|
+
const skinQuips = { tired: [] };
|
|
39
|
+
const r = pickQuip('tired', skinQuips);
|
|
40
|
+
assert(UNIVERSAL_QUIPS.tired.includes(r), `皮肤池 tired 为空数组 → 回退 UNIVERSAL_QUIPS.tired(实际:"${r}")`);
|
|
41
|
+
}
|
|
42
|
+
// ── 4) 未传皮肤池 → 回退通用池 ──────────────────────────────────────────
|
|
43
|
+
{
|
|
44
|
+
const r = pickQuip('tired');
|
|
45
|
+
assert(UNIVERSAL_QUIPS.tired.includes(r), `未传皮肤池(tired)→ 回退 UNIVERSAL_QUIPS.tired(实际:"${r}")`);
|
|
46
|
+
}
|
|
47
|
+
// ── 5) 5 种 MoodKind 各跑一次"未传皮肤池"场景 → 都能返回该 mood 池里的某一条 ──
|
|
48
|
+
for (const mood of ALL_MOODS) {
|
|
49
|
+
const r = pickQuip(mood);
|
|
50
|
+
assert(typeof r === 'string' && UNIVERSAL_QUIPS[mood].includes(r), `未传皮肤池(${mood})→ 返回值 ∈ UNIVERSAL_QUIPS.${mood}(实际:"${r}")`);
|
|
51
|
+
}
|
|
52
|
+
// ── 6) 多次调用同一场景(20 次 pickQuip('tired'))→ 每次都不跑出通用池范围 ──
|
|
53
|
+
for (let i = 0; i < 20; i++) {
|
|
54
|
+
const r = pickQuip('tired');
|
|
55
|
+
assert(UNIVERSAL_QUIPS.tired.includes(r), `第 ${i + 1} 次调用 pickQuip('tired')→ 返回值 ∈ UNIVERSAL_QUIPS.tired(实际:"${r}")`);
|
|
56
|
+
}
|
|
57
|
+
console.log(`\nOK: ${passed} passed, 0 failed`);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Mood class 切换 / 气泡淡出 / 皮肤动作覆盖 <link> 回退——纯逻辑部分,从 renderer.ts 抽出。
|
|
2
|
+
// 不 import 'electron',不引用真实的全局 window/document,只操作调用方传入的、符合
|
|
3
|
+
// "最小 DOM 元素形状"接口(ElementLike/LinkElementLike)的对象,使这部分逻辑可以脱离真实浏览器
|
|
4
|
+
// 环境(本项目未安装 jsdom)被单元测试覆盖——与任务 4 里 main.ts → mood-tracker.ts 的抽取方式同构。
|
|
5
|
+
/** 气泡文案自动淡出隐藏前的展示时长(design.md "情绪 → 演出的驱动"默认假设)。 */
|
|
6
|
+
export const QUIP_VISIBLE_MS = 3000;
|
|
7
|
+
export const ALL_MOOD_CLASSES = [
|
|
8
|
+
'mood-tired',
|
|
9
|
+
'mood-bored',
|
|
10
|
+
'mood-urging',
|
|
11
|
+
'mood-frustrated',
|
|
12
|
+
'mood-flustered',
|
|
13
|
+
];
|
|
14
|
+
/** MoodKind → CSS class 映射表(与 state class 独立叠加,不互相替换,见 renderer.ts STATE_CLASS 同一写法风格)。 */
|
|
15
|
+
export const MOOD_CLASS = {
|
|
16
|
+
frustrated: 'mood-frustrated',
|
|
17
|
+
flustered: 'mood-flustered',
|
|
18
|
+
urging: 'mood-urging',
|
|
19
|
+
tired: 'mood-tired',
|
|
20
|
+
bored: 'mood-bored',
|
|
21
|
+
};
|
|
22
|
+
/** 应用 mood class:mood 为 null 时移除全部 mood class(五种互斥,批量移除后按需添加一个)。 */
|
|
23
|
+
export function applyMood(container, mood) {
|
|
24
|
+
container.classList.remove(...ALL_MOOD_CLASSES);
|
|
25
|
+
if (mood !== null) {
|
|
26
|
+
container.classList.add(MOOD_CLASS[mood]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** 设置/清空皮肤个性化动作覆盖 <link> 的 href:有 motionFile 则指向该皮肤的个性化动作覆盖 CSS,
|
|
30
|
+
* 否则清空(浏览器 <link> 加载失败/空 href 本身不抛异常,天然静默回退到通用样式,见 design.md)。 */
|
|
31
|
+
export function applySkinMotion(link, motionFile) {
|
|
32
|
+
link.href = motionFile ? `../assets/pets/${motionFile}` : '';
|
|
33
|
+
}
|
|
34
|
+
const defaultSetTimeout = (callback, ms) => setTimeout(callback, ms);
|
|
35
|
+
const defaultClearTimeout = (handle) => clearTimeout(handle);
|
|
36
|
+
/**
|
|
37
|
+
* 创建一个独立的气泡淡出控制器。用工厂函数注入 setTimeout/clearTimeout 而非依赖模块级
|
|
38
|
+
* 共享的定时器句柄变量,便于测试里传入模拟定时器(不必真实等待 QUIP_VISIBLE_MS)。
|
|
39
|
+
*/
|
|
40
|
+
export function createBubbleController(setTimeoutFn = defaultSetTimeout, clearTimeoutFn = defaultClearTimeout) {
|
|
41
|
+
let hideTimer = null;
|
|
42
|
+
function showQuip(bubbleEl, quip, visibleMs) {
|
|
43
|
+
bubbleEl.textContent = quip;
|
|
44
|
+
bubbleEl.classList.add('pet-bubble-visible');
|
|
45
|
+
if (hideTimer !== null) {
|
|
46
|
+
clearTimeoutFn(hideTimer);
|
|
47
|
+
}
|
|
48
|
+
hideTimer = setTimeoutFn(() => {
|
|
49
|
+
bubbleEl.classList.remove('pet-bubble-visible');
|
|
50
|
+
}, visibleMs);
|
|
51
|
+
}
|
|
52
|
+
return { showQuip };
|
|
53
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// dom-mood.ts 的单元测试(tasks.md 6.1):mood class 切换、气泡淡出、皮肤动作覆盖 <link> 回退。
|
|
2
|
+
// 不依赖 jsdom——用手写的最小 stub 对象(ElementLike/LinkElementLike 形状)覆盖场景,
|
|
3
|
+
// 气泡淡出场景额外用手写假定时器替代真实 setTimeout/clearTimeout,避免测试真实等待 3000ms。
|
|
4
|
+
//
|
|
5
|
+
// 运行:npx tsx packages/pet-app/src/renderer/dom-mood.test.ts
|
|
6
|
+
import { applyMood, applySkinMotion, createBubbleController, ALL_MOOD_CLASSES, } from './dom-mood.js';
|
|
7
|
+
let passed = 0;
|
|
8
|
+
const assert = (cond, msg) => {
|
|
9
|
+
if (!cond) {
|
|
10
|
+
console.error('✗ ' + msg);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
passed++;
|
|
14
|
+
console.log('✓ ' + msg);
|
|
15
|
+
};
|
|
16
|
+
/** 基于 Set<string> 实现的最小 classList stub,不依赖 jsdom/真实 DOMTokenList。 */
|
|
17
|
+
function createStubElement() {
|
|
18
|
+
const classes = new Set();
|
|
19
|
+
return {
|
|
20
|
+
classList: {
|
|
21
|
+
add(...tokens) {
|
|
22
|
+
for (const t of tokens)
|
|
23
|
+
classes.add(t);
|
|
24
|
+
},
|
|
25
|
+
remove(...tokens) {
|
|
26
|
+
for (const t of tokens)
|
|
27
|
+
classes.delete(t);
|
|
28
|
+
},
|
|
29
|
+
contains(token) {
|
|
30
|
+
return classes.has(token);
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
textContent: null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// ── 场景A:mood class 切换,始终只有一个 ──────────────────────────────────
|
|
37
|
+
{
|
|
38
|
+
const el = createStubElement();
|
|
39
|
+
const assertOnlyMoodClass = (activeClass, label) => {
|
|
40
|
+
for (const cls of ALL_MOOD_CLASSES) {
|
|
41
|
+
const shouldContain = cls === activeClass;
|
|
42
|
+
assert(el.classList.contains(cls) === shouldContain, `${label}:class ${cls} ${shouldContain ? '应存在' : '应不存在'}`);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
applyMood(el, 'tired');
|
|
46
|
+
assertOnlyMoodClass('mood-tired', "applyMood(el, 'tired') 后");
|
|
47
|
+
applyMood(el, 'frustrated');
|
|
48
|
+
assertOnlyMoodClass('mood-frustrated', "applyMood(el, 'frustrated') 后(上一个 mood class 应已被移除)");
|
|
49
|
+
applyMood(el, 'bored');
|
|
50
|
+
assertOnlyMoodClass('mood-bored', "applyMood(el, 'bored') 后");
|
|
51
|
+
applyMood(el, null);
|
|
52
|
+
assertOnlyMoodClass(null, 'applyMood(el, null) 后(全部 mood class 应被移除)');
|
|
53
|
+
}
|
|
54
|
+
// ── 场景B:气泡文案自动淡出,不影响 mood class ────────────────────────────
|
|
55
|
+
{
|
|
56
|
+
let nextId = 1;
|
|
57
|
+
let queue = [];
|
|
58
|
+
let clearCallCount = 0;
|
|
59
|
+
const fakeSetTimeout = (fn, ms) => {
|
|
60
|
+
const id = nextId++;
|
|
61
|
+
queue.push({ id, fn, ms });
|
|
62
|
+
return id;
|
|
63
|
+
};
|
|
64
|
+
const fakeClearTimeout = (handle) => {
|
|
65
|
+
clearCallCount++;
|
|
66
|
+
queue = queue.filter((t) => t.id !== handle);
|
|
67
|
+
};
|
|
68
|
+
const flush = () => {
|
|
69
|
+
const toRun = queue;
|
|
70
|
+
queue = [];
|
|
71
|
+
for (const t of toRun)
|
|
72
|
+
t.fn();
|
|
73
|
+
};
|
|
74
|
+
const bubbleController = createBubbleController(fakeSetTimeout, fakeClearTimeout);
|
|
75
|
+
const bubbleEl = createStubElement();
|
|
76
|
+
const stageEl = createStubElement();
|
|
77
|
+
applyMood(stageEl, 'tired');
|
|
78
|
+
bubbleController.showQuip(bubbleEl, '测试文案', 3000);
|
|
79
|
+
assert(bubbleEl.textContent === '测试文案', 'showQuip 后 bubbleEl.textContent 应为传入的文案');
|
|
80
|
+
assert(bubbleEl.classList.contains('pet-bubble-visible') === true, 'showQuip 后 bubbleEl 应带有 pet-bubble-visible class(淡入)');
|
|
81
|
+
flush();
|
|
82
|
+
assert(bubbleEl.classList.contains('pet-bubble-visible') === false, 'flush 假定时器后 bubbleEl 应移除 pet-bubble-visible class(淡出)');
|
|
83
|
+
assert(bubbleEl.textContent === '测试文案', 'flush 假定时器后 bubbleEl.textContent 仍保留原文案(淡出不清空文本)');
|
|
84
|
+
assert(stageEl.classList.contains('mood-tired') === true, '气泡淡出不影响舞台元素的 mood class(两者互不干扰)');
|
|
85
|
+
// 额外验证:连续调用两次 showQuip,第一次的定时器应被 clearTimeoutFn 正确清除。
|
|
86
|
+
bubbleController.showQuip(bubbleEl, '第一条', 3000);
|
|
87
|
+
const clearCountBefore = clearCallCount;
|
|
88
|
+
const queueLenAfterFirst = queue.length;
|
|
89
|
+
bubbleController.showQuip(bubbleEl, '第二条', 3000);
|
|
90
|
+
assert(clearCallCount === clearCountBefore + 1, '第二次 showQuip 调用应触发一次 clearTimeoutFn(清除第一次的定时器)');
|
|
91
|
+
assert(queue.length === queueLenAfterFirst, '第二次 showQuip 后队列里第一次的定时器已被移除,队列长度不因残留旧定时器而增长');
|
|
92
|
+
assert(bubbleEl.textContent === '第二条', '第二次 showQuip 后文案应更新为最新一次的内容');
|
|
93
|
+
}
|
|
94
|
+
// ── 场景C:皮肤动作覆盖 link 回退 ─────────────────────────────────────────
|
|
95
|
+
{
|
|
96
|
+
const link = { href: '' };
|
|
97
|
+
applySkinMotion(link, '01-robo-cat.motion.css');
|
|
98
|
+
assert(link.href === '../assets/pets/01-robo-cat.motion.css', "applySkinMotion(link, '01-robo-cat.motion.css') 应设置对应路径的 href");
|
|
99
|
+
applySkinMotion(link, undefined);
|
|
100
|
+
assert(link.href === '', 'applySkinMotion(link, undefined) 应清空 href(回退通用样式)');
|
|
101
|
+
applySkinMotion(link, '02-robo-dog.motion.css');
|
|
102
|
+
applySkinMotion(link);
|
|
103
|
+
assert(link.href === '', '再次设置后不传第二参数调用 applySkinMotion(link),最终 href 应被清空');
|
|
104
|
+
}
|
|
105
|
+
console.log(`\nOK: ${passed} passed, 0 failed`);
|
package/dist/renderer/index.html
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self' data:; connect-src 'self' file:;" />
|
|
6
6
|
<title>mocode pet</title>
|
|
7
7
|
<link rel="stylesheet" href="style.css" />
|
|
8
|
+
<link id="pet-skin-motion" rel="stylesheet" href="" />
|
|
8
9
|
</head>
|
|
9
10
|
<body>
|
|
10
11
|
<div id="pet-stage" class="pet-idle">
|
|
11
12
|
<div id="pet-container"></div>
|
|
12
13
|
<div id="signal-light-container"></div>
|
|
14
|
+
<div id="pet-bubble"></div>
|
|
13
15
|
</div>
|
|
14
16
|
<script src="renderer.js" type="module"></script>
|
|
15
17
|
</body>
|
package/dist/renderer/preload.js
CHANGED
|
@@ -9,14 +9,21 @@ electron_1.contextBridge.exposeInMainWorld('petBridge', {
|
|
|
9
9
|
callback(payload.state, payload.meta);
|
|
10
10
|
});
|
|
11
11
|
},
|
|
12
|
-
/** 主进程推来的皮肤切换通知(运行期切换:托盘菜单选择 或 CLI set_skin 消息触发)。
|
|
12
|
+
/** 主进程推来的皮肤切换通知(运行期切换:托盘菜单选择 或 CLI set_skin 消息触发)。
|
|
13
|
+
* motionFile 有值时指向该皮肤的个性化动作覆盖 CSS(见 renderer.ts applySkinMotion),缺失表示回退通用样式。 */
|
|
13
14
|
onSkin: (callback) => {
|
|
14
15
|
electron_1.ipcRenderer.on('pet:skin', (_event, payload) => {
|
|
15
|
-
callback(payload
|
|
16
|
+
callback(payload);
|
|
16
17
|
});
|
|
17
18
|
},
|
|
18
19
|
/** 启动时主动拉取当前皮肤(避免 did-finish-load 推送与监听器注册之间的时序竞争)。 */
|
|
19
20
|
getInitialSkin: () => electron_1.ipcRenderer.invoke('pet:get-skin'),
|
|
21
|
+
/** 主进程推来的 mood 求值结果(见 main.ts pushMoodEvaluation),mood 为 null 表示当前无需展示情绪演出。 */
|
|
22
|
+
onMood: (callback) => {
|
|
23
|
+
electron_1.ipcRenderer.on('pet:mood', (_event, payload) => {
|
|
24
|
+
callback(payload.mood, payload.quip);
|
|
25
|
+
});
|
|
26
|
+
},
|
|
20
27
|
/** 转发鼠标穿透状态请求(拖拽放置功能:悬停/拖拽时取消穿透,离开后恢复穿透)。 */
|
|
21
28
|
setIgnoreMouseEvents: (ignore) => {
|
|
22
29
|
electron_1.ipcRenderer.send('pet:set-ignore-mouse-events', ignore);
|