dsh-pet-live2d-viola 1.1.0 → 1.1.2

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/README.md CHANGED
@@ -71,6 +71,49 @@ DeepSeek Harness Web GUI 的 **Live2D 桌宠插件**,一台桌宠里可以切
71
71
 
72
72
  每位角色有自己的待机动作池,每 12 秒换一次姿势,并按各自的静默权重决定要不要说一句话。都子的待机台词与特定动作绑定。
73
73
 
74
+ ## 随机模式
75
+
76
+ **右键 → 设置 → 随机模式**,二级界面:
77
+
78
+ ```
79
+ ← 返回 随机模式
80
+  开启随机模式
81
+ ──────────────────
82
+ 随机内容
83
+ ✓ 随机动作+表情
84
+  随机动作
85
+  随机表情
86
+ ```
87
+
88
+ 开启后,待机时**每 12 秒**按所选范围重新随机。三种范围:
89
+
90
+ | 范围 | 动作 | 表情 | 语音 |
91
+ |---|---|---|---|
92
+ | 随机动作 | 每 12 秒随机 | 保持你选的脸 | 静音 |
93
+ | 随机表情 | 照常 | 每 12 秒随机 | 静音 |
94
+ | 随机动作+表情 | 每 12 秒随机 | 每 12 秒随机 | 静音 |
95
+
96
+ - **只在待机状态生效** —— 思考/工作/完成等阶段保持各阶段的正常行为
97
+ - **任意范围都静音**:语音带自己的字幕和借来的脸,会打断随机序列
98
+ - 表情池 = **菜单里能选的那些**(排除内部生成的 `_nomouth` 变体)
99
+ - 抽取**不会选中当前已在用的**表情,避免连续两次相同
100
+ - 开关与范围是**两个独立字段**,所以关掉再打开会记住上次选的范围
101
+ - 默认**关闭**。待机漂移本来只动身体不动脸,因为自己乱变的脸会覆盖你手动选的表情 —— 这是有意的默认值
102
+
103
+ ## 功能简介
104
+
105
+ **右键 → 设置 → 功能简介** —— 只读面板,介绍当前人物的能力。
106
+
107
+ **所有数字都是实时统计的**,从菜单用的同一批表里数出来,所以加了语音或动作会自动更新:
108
+
109
+ | 项 | 来源 |
110
+ |---|---|
111
+ | 动作数 | 动作组里以 `_C` 结尾的(与动作菜单一致) |
112
+ | 表情数 | 排除内部组合(与表情菜单一致) |
113
+ | 待机动作数 | 本角色的待机池 |
114
+ | 语音条数 | 点击 + 阶段反应 + 待机 + 下线 |
115
+ | 随机模式 | 说明 + **当前是否开启及所选范围** |
116
+
74
117
  ## 取景
75
118
 
76
119
  三种取景:**全身 / 四分之三身 / 半身**。
package/lib/client.js CHANGED
@@ -100,22 +100,36 @@ window.__ModuleLoader__.load({
100
100
  * @see aspect
101
101
  */
102
102
  const SURFACE_ASPECT_SCALE = {
103
- yuno: { default: 1.15, full: 1, threeQuarter: 1.1, bust: 1.28 },
104
- yuno_virtual: { default: 1.15, full: 1, threeQuarter: 1.1, bust: 1.28 }
103
+ /*
104
+ * The shared entry: every model falls back to `default`, so a character only needs its own line to differ.
105
+ *
106
+ * `full` is named explicitly because `default` also covers crops with no entry — a bare `default: 0.9` would
107
+ * narrow EVERY crop, which is not the same thing.
108
+ */
109
+ default: { default: 1, full: 0.7, threeQuarter: 0.85 },
110
+ yuno: { default: 1.15, full: 0.7, threeQuarter: 1.1, bust: 1.28 },
111
+ yuno_virtual: { default: 1.15, full: 0.7, threeQuarter: 1.1, bust: 1.28 }
105
112
  };
106
113
  /**
107
114
  * The framing-box width multiplier for a character and crop.
108
115
  *
116
+ * Resolution order: the character's own entry, then the shared `default` entry, then 1. Within an entry, a crop
117
+ * missing from it falls back to that entry's `default`, so a character can widen one crop without listing the
118
+ * others.
119
+ *
109
120
  * @param character - the active model id.
110
121
  * @param crop - the framing preset key.
111
122
  * @returns the multiplier on the surface width; 1 when nothing is configured.
112
123
  */
113
124
  function aspectScaleFor(character, crop) {
114
- const entry = SURFACE_ASPECT_SCALE[character];
115
- if (entry === undefined) return 1;
116
- // A plain number applies to every crop; an object may override per crop with `default` as the fallback.
117
- const value = typeof entry === "number" ? entry : (entry[crop] ?? entry.default);
118
- return typeof value === "number" && value > 0 ? value : 1;
125
+ const resolve = (entry) => {
126
+ if (entry === undefined) return undefined;
127
+ if (typeof entry === "number") return entry;
128
+ const value = entry[crop] ?? entry.default;
129
+ return typeof value === "number" ? value : undefined;
130
+ };
131
+ const value = resolve(SURFACE_ASPECT_SCALE[character]) ?? resolve(SURFACE_ASPECT_SCALE.default) ?? 1;
132
+ return value > 0 ? value : 1;
119
133
  }
120
134
  /**
121
135
  * Vertical position adjustment for a widened box, as a fraction of the box HEIGHT.
@@ -135,21 +149,36 @@ window.__ModuleLoader__.load({
135
149
  * line. Change a number here; nothing else has to move.
136
150
  */
137
151
  const SURFACE_Y_ADJUST = {
138
- yuno: { default: 0.12, full: 0, threeQuarter: 0.08, bust: 0.3 },
139
- yuno_virtual: { default: 0.12, full: 0, threeQuarter: 0.08, bust: 0.3 }
152
+ /*
153
+ * The shared entry, like the one on `SURFACE_ASPECT_SCALE`: every model falls back to it, so a value meant for
154
+ * everyone is written once. Per character only where it has to differ.
155
+ *
156
+ * `full` begins at 0 — the un-widened box's own framing, which is the right starting point for a crop that has
157
+ * not needed adjustment.
158
+ */
159
+ default: { default: 0, full: -0.18, threeQuarter: -0.14 },
160
+ yuno: { default: 0.12, full: -0.18, threeQuarter: 0.08, bust: 0.3 },
161
+ yuno_virtual: { default: 0.12, full: -0.18, threeQuarter: 0.08, bust: 0.3 }
140
162
  };
141
163
  /**
142
164
  * The vertical adjustment for a character and crop.
143
165
  *
166
+ * Resolution order mirrors `aspectScaleFor`: the character's own entry, then the shared `default` entry, then 0.
167
+ * Within an entry, a crop missing from it falls back to that entry's `default`.
168
+ *
144
169
  * @param character - the active model id.
145
170
  * @param crop - the framing preset key.
146
171
  * @returns the fraction of box height to shift by; 0 when nothing is configured.
147
172
  */
148
173
  function yAdjustFor(character, crop) {
149
- const perChar = SURFACE_Y_ADJUST[character];
150
- if (perChar === undefined) return 0;
151
- const value = perChar[crop] ?? perChar.default;
152
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
174
+ const resolve = (entry) => {
175
+ if (entry === undefined) return undefined;
176
+ if (typeof entry === "number") return entry;
177
+ const value = entry[crop] ?? entry.default;
178
+ return typeof value === "number" ? value : undefined;
179
+ };
180
+ const value = resolve(SURFACE_Y_ADJUST[character]) ?? resolve(SURFACE_Y_ADJUST.default) ?? 0;
181
+ return Number.isFinite(value) ? value : 0;
153
182
  }
154
183
 
155
184
  /**
@@ -163,15 +192,33 @@ window.__ModuleLoader__.load({
163
192
  * preset's zoom, so a tighter crop shifts further.
164
193
  */
165
194
  const SURFACE_X_ADJUST = {
166
- yuno: { default: 0, bust: 0 },
167
- yuno_virtual: { default: 0, bust: 0 }
195
+ /*
196
+ * The shared entry, mirroring `SURFACE_Y_ADJUST`: every model falls back to it, so a value meant for everyone
197
+ * is written once. Without it, a request to move "everyone" would silently apply to 由乃 alone.
198
+ */
199
+ default: { default: 0, full: 0.07, threeQuarter: -0.02 },
200
+ yuno: { default: 0, full: 0.07, bust: 0 },
201
+ yuno_virtual: { default: 0, full: 0.07, bust: 0 }
168
202
  };
169
- /** The horizontal adjustment for a character and crop. @see SURFACE_X_ADJUST */
203
+ /**
204
+ * The horizontal adjustment for a character and crop.
205
+ *
206
+ * Resolution order mirrors `aspectScaleFor` / `yAdjustFor`: the character's own entry, then the shared `default`
207
+ * entry, then 0. Reading only the character's own entry is what made an "everyone" change apply to 由乃 alone.
208
+ *
209
+ * @param character - the active model id.
210
+ * @param crop - the framing preset key.
211
+ * @returns the fraction of box width to shift by; 0 when nothing is configured.
212
+ */
170
213
  function xAdjustFor(character, crop) {
171
- const perChar = SURFACE_X_ADJUST[character];
172
- if (perChar === undefined) return 0;
173
- const value = perChar[crop] ?? perChar.default;
174
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
214
+ const resolve = (entry) => {
215
+ if (entry === undefined) return undefined;
216
+ if (typeof entry === "number") return entry;
217
+ const value = entry[crop] ?? entry.default;
218
+ return typeof value === "number" ? value : undefined;
219
+ };
220
+ const value = resolve(SURFACE_X_ADJUST[character]) ?? resolve(SURFACE_X_ADJUST.default) ?? 0;
221
+ return Number.isFinite(value) ? value : 0;
175
222
  }
176
223
  /**
177
224
  * Extra surface height, as a multiple of the base height, added BELOW the character.
@@ -1599,6 +1646,32 @@ window.__ModuleLoader__.load({
1599
1646
  "pet.menu.frame": "显示取景框边界",
1600
1647
  "pet.menu.blink": "自动眨眼",
1601
1648
  "pet.menu.gaze": "视线追随鼠标",
1649
+ "pet.menu.randomMode": "随机模式",
1650
+ "pet.menu.title.random": "随机模式",
1651
+ "pet.menu.randomModeOn": "开启随机模式",
1652
+ "pet.menu.randomScope": "随机内容",
1653
+ "pet.menu.randomMotion": "随机动作",
1654
+ "pet.menu.randomExpression": "随机表情",
1655
+ "pet.menu.randomBoth": "随机动作+表情",
1656
+ "pet.menu.about": "功能简介",
1657
+ "pet.menu.title.about": "功能简介",
1658
+ "pet.about.who": "当前人物",
1659
+ "pet.about.pool": "动作与表情",
1660
+ "pet.about.pool.text": "{motions} 个动作、{expressions} 个表情,右键菜单里可直接挑选",
1661
+ "pet.about.idle": "待机",
1662
+ "pet.about.idle.text": "每 12 秒从 {count} 个待机动作里随机换一次姿势",
1663
+ "pet.about.voice": "语音",
1664
+ "pet.about.voice.click": "点击 {count} 条",
1665
+ "pet.about.voice.phase": "阶段反应 {count} 条",
1666
+ "pet.about.voice.idle": "待机 {count} 条",
1667
+ "pet.about.voice.farewell": "下线 1 条",
1668
+ "pet.about.voice.none": "这个人物还没有语音",
1669
+ "pet.about.listSep": "、",
1670
+ "pet.about.random": "随机模式",
1671
+ "pet.about.random.text": "在「设置 → 随机模式」里开启。开启后待机时每 12 秒按所选范围重新随机,并且不出声;可选只为动作、只为表情,或两者都随机。",
1672
+ "pet.about.random.now": "当前:已开启({scope})",
1673
+ "pet.about.how": "操作",
1674
+ "pet.about.how.text": "左键拖动移动、滚轮缩放、点击说话、右键打开菜单。",
1602
1675
  "pet.menu.submenuHint": "点击进入",
1603
1676
  "pet.menu.volume": "语音音量",
1604
1677
  "pet.menu.title.volume": "语音音量(0 为静音)",
@@ -1724,6 +1797,32 @@ window.__ModuleLoader__.load({
1724
1797
  "pet.menu.frame": "Show framing boundary",
1725
1798
  "pet.menu.blink": "Auto blink",
1726
1799
  "pet.menu.gaze": "Eyes follow pointer",
1800
+ "pet.menu.randomMode": "Random mode",
1801
+ "pet.menu.title.random": "Random mode",
1802
+ "pet.menu.randomModeOn": "Enable random mode",
1803
+ "pet.menu.randomScope": "What to randomise",
1804
+ "pet.menu.randomMotion": "Random motions",
1805
+ "pet.menu.randomExpression": "Random expressions",
1806
+ "pet.menu.randomBoth": "Both motion and expression",
1807
+ "pet.menu.about": "About this character",
1808
+ "pet.menu.title.about": "About this character",
1809
+ "pet.about.who": "Currently showing",
1810
+ "pet.about.pool": "Motions and expressions",
1811
+ "pet.about.pool.text": "{motions} motions and {expressions} expressions, all pickable from the right-click menu",
1812
+ "pet.about.idle": "Idling",
1813
+ "pet.about.idle.text": "Changes pose every 12 seconds, drawn from {count} idle motions",
1814
+ "pet.about.voice": "Voice",
1815
+ "pet.about.voice.click": "{count} on click",
1816
+ "pet.about.voice.phase": "{count} phase reactions",
1817
+ "pet.about.voice.idle": "{count} idle lines",
1818
+ "pet.about.voice.farewell": "1 goodbye",
1819
+ "pet.about.voice.none": "No voice lines yet for this character",
1820
+ "pet.about.listSep": ", ",
1821
+ "pet.about.random": "Random mode",
1822
+ "pet.about.random.text": "Turn it on under Settings → Random mode. While it is on, the idle pose is redrawn every 12 seconds according to the scope you pick, and she stays silent; the scope can be motions only, expressions only, or both.",
1823
+ "pet.about.random.now": "Currently on ({scope})",
1824
+ "pet.about.how": "Controls",
1825
+ "pet.about.how.text": "Drag to move, scroll to zoom, click to talk, right-click for the menu.",
1727
1826
  "pet.menu.submenuHint": "open",
1728
1827
  "pet.menu.volume": "Voice volume",
1729
1828
  "pet.menu.title.volume": "Voice volume (0 = muted)",
@@ -2335,8 +2434,26 @@ window.__ModuleLoader__.load({
2335
2434
  bottom: calc(100% - var(--bubble-offset, 10px) * var(--bubble-scale, 1));
2336
2435
  top: auto;
2337
2436
  transform: translateX(-50%);
2338
- max-width: min(94%, 400px);
2339
- min-width: 84px;
2437
+ /*
2438
+ * An EXPLICIT width, because the percentage offset would otherwise decide it.
2439
+ *
2440
+ * The element is absolutely positioned with left:50%, and for those the shrink-to-fit width has the offset
2441
+ * subtracted from the containing block: in a 246 px frame the available width is only 246 - 123 = 123 px. That is why
2442
+ * raising max-width had no effect — measured, the rendered width stayed at exactly half the frame (123 / 246), and
2443
+ * removing max-width, zeroing min-width and clearing the transform ALL also left it at 123.
2444
+ *
2445
+ * Setting the width outright takes it out of that calculation, so the caption is sized by the text and by how big the
2446
+ * pet is, not by how far the frame has been cropped. Measured after the change: 400 px in every crop.
2447
+ *
2448
+ * All three numbers are independent of the frame:
2449
+ * 351px * --bubble-scale tracks the pet's own size, so a bigger pet gets a bigger caption
2450
+ * 400px the ceiling, unchanged from before
2451
+ * 84px * --bubble-scale the floor, so a two-word line is not a sliver
2452
+ * max-content hugs the text rather than filling the width
2453
+ */
2454
+ width: max-content;
2455
+ max-width: min(calc(351px * var(--bubble-scale, 1)), 400px);
2456
+ min-width: calc(84px * var(--bubble-scale, 1));
2340
2457
  /*
2341
2458
  * Sized from --bubble-scale, which the layout sets to the model's own scale.
2342
2459
  *
@@ -2434,6 +2551,13 @@ window.__ModuleLoader__.load({
2434
2551
  font: 12px/1.5 system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
2435
2552
  }
2436
2553
  .dsh-l2d-menu-title { padding: 5px 8px 3px; opacity: .6; font-size: 11px; letter-spacing: .04em; }
2554
+ /*
2555
+ * A read-only line inside the menu, for the description panel.
2556
+ *
2557
+ * Wraps and stays dimmer than a button, so it reads as copy rather than as something to press — the panel has no actions,
2558
+ * and a row that looks clickable but does nothing is worse than plain text.
2559
+ */
2560
+ .dsh-l2d-menu-text { padding: 0 8px 6px; opacity: .85; font-size: 12px; line-height: 1.5; white-space: normal; }
2437
2561
  .dsh-l2d-menu button {
2438
2562
  display: block;
2439
2563
  width: 100%;
@@ -2729,6 +2853,26 @@ window.__ModuleLoader__.load({
2729
2853
  */
2730
2854
  this.blinkEnabled = options.blink !== false;
2731
2855
  this.gazeEnabled = options.gaze !== false;
2856
+ /**
2857
+ * Random mode, pushed in from the layout like the two above.
2858
+ *
2859
+ * `randomMode` says whether it is on; `randomScope` says what it randomises — motions, expressions, or both.
2860
+ * Two fields rather than one enum with an "off" member, so switching the mode off and on again restores the
2861
+ * scope that was chosen rather than resetting it.
2862
+ *
2863
+ * Read from the options rather than assigned literals so the FIRST frame already respects them; later changes
2864
+ * arrive through {@link setRandomMode}. @see randomScope
2865
+ */
2866
+ this.randomMode = options.randomMode === true;
2867
+ this.randomScope = options.randomScope ?? "both";
2868
+ /**
2869
+ * The expressions random mode may draw from, pushed in by the pet.
2870
+ *
2871
+ * It CANNOT be read from the model: `Live2DModel` exposes no `.expressions` — measured on a loaded instance,
2872
+ * the property is `undefined` — so the only list of names is the one the host sends in the snapshot, which
2873
+ * only the pet has. @see drawIdleFace
2874
+ */
2875
+ this.expressionPool = Array.isArray(options.expressionPool) ? options.expressionPool : [];
2732
2876
  /**
2733
2877
  * How the drift hands a motion to the pet so its line (voice AND caption) can be
2734
2878
  * played: `(motion) => void`.
@@ -3934,9 +4078,11 @@ window.__ModuleLoader__.load({
3934
4078
  /**
3935
4079
  * Play one drift clip for the current phase.
3936
4080
  *
3937
- * MOVES ONLY. The face used to be randomised here too, which meant her expression
3938
- * drifted on its own and silently overwrote whatever was picked from the menu. Her
3939
- * expression now changes only when one is chosen.
4081
+ * MOVES ONLY, unless RANDOM MODE is on. The face used to be randomised here unconditionally, which meant her
4082
+ * expression drifted on its own and silently overwrote whatever was picked from the menu. Her expression now
4083
+ * changes only when one is chosen — except while the user has switched random mode on, where pairing a random
4084
+ * face with the random motion is the whole feature. An explicit mode is the difference: a face that changes on
4085
+ * its own is a defect, the same behaviour behind a switch the user threw is not.
3940
4086
  *
3941
4087
  * A click animation outranks this entirely: while its hold window is open nothing is
3942
4088
  * attempted, so the startle cannot be cut short even by a drift that was already due.
@@ -3945,6 +4091,22 @@ window.__ModuleLoader__.load({
3945
4091
  if (this.clickHoldsModel()) return;
3946
4092
  const binding = this.phaseAnimation ?? PHASE_ANIMATION.idle;
3947
4093
  const motions = binding.idleMotions ?? PHASE_ANIMATION.idle.idleMotions;
4094
+ const random = this.randomModeEnabled();
4095
+ /*
4096
+ * RANDOM MODE, three scopes.
4097
+ *
4098
+ * `motion` — the motion is drawn at random (the weighted pool still applies) and the face is left alone.
4099
+ * That is what the drift already does, so this scope changes nothing except that it is now an
4100
+ * explicit choice; it exists so the menu can express it, and so a user who wants a still face
4101
+ * with lively motion has somewhere to say so.
4102
+ * `expression` — the motion follows the pool and a new face is drawn.
4103
+ * `both` — a fresh motion AND a fresh face, which is the pairing this mode was built for.
4104
+ *
4105
+ * The face is drawn BEFORE the empty-pool early return, because it does not depend on the motion pool: a
4106
+ * phase with no motions would otherwise freeze the face as well as the body, and the mode would look like it
4107
+ * worked in some states and not others.
4108
+ */
4109
+ if (random && this.randomFaces()) this.drawIdleFace();
3948
4110
  if (motions.length === 0) return;
3949
4111
  // Avoid repeating the clip already playing, so the drift reads as activity
3950
4112
  // rather than as one motion stuttering on a timer.
@@ -3961,7 +4123,87 @@ window.__ModuleLoader__.load({
3961
4123
  * because `thinking` uses a different set from every other phase and offering both would
3962
4124
  * let the two cancel each other — a second request is dropped while one is playing.
3963
4125
  */
3964
- this.offerLine(motion);
4126
+ if (!this.randomSilencesVoice(random)) this.offerLine(motion);
4127
+ }
4128
+
4129
+ /**
4130
+ * True while the user has random mode switched on AND a drift slot is allowed to use it.
4131
+ *
4132
+ * The switch lives on this object, pushed in by the pet's `applyLayout` — the stage holds no layout of its own,
4133
+ * which is why this is a field rather than a read of `options.layout`.
4134
+ *
4135
+ * Two conditions beyond the switch:
4136
+ *
4137
+ * `idlePhase` must BE `idle`, or be unset. The mode is specified for the resting state, and letting it run
4138
+ * during `listening`, `thinking`, `working` or `done` would overwrite the face each of those reactions pairs
4139
+ * with its own line — the reaction plays, and a moment later the face it chose is replaced. `undefined` is
4140
+ * accepted because the drift can run before any phase has been seen, which is the resting state.
4141
+ *
4142
+ * A click must not own the model. `playPhaseIdle` already refuses to play while one does, so this only keeps the
4143
+ * face consistent with the body it is decided alongside.
4144
+ */
4145
+ randomModeEnabled() {
4146
+ if (this.randomMode !== true) return false;
4147
+ if (this.idlePhase !== undefined && this.idlePhase !== "idle") return false;
4148
+ return !this.clickHoldsModel();
4149
+ }
4150
+
4151
+ /** True when the active scope draws a new FACE on each slot. @see randomScope */
4152
+ randomFaces() {
4153
+ return this.randomScope === "expression" || this.randomScope === "both";
4154
+ }
4155
+
4156
+ /**
4157
+ * True when the drift must stay SILENT for this slot.
4158
+ *
4159
+ * ANY active random-mode scope silences the voice. A clip carries its own caption and borrows its own face, so a
4160
+ * line interrupts the sequence the mode is running — and for the face-drawing scopes the two would also fight
4161
+ * over the expression parameter, the line winning until it ended and the drift then overwriting the face it had
4162
+ * picked. `motion` has no such conflict, but a drift that speaks in one scope and not another reads as the mode
4163
+ * being unreliable rather than as a rule, so the whole mode is quiet.
4164
+ *
4165
+ * Called with the already-computed `random` flag so the condition is evaluated once per slot.
4166
+ */
4167
+ randomSilencesVoice(random) {
4168
+ return random === true;
4169
+ }
4170
+
4171
+ /** Set random mode for the character now showing, from the persisted layout. */
4172
+ setRandomMode(on) {
4173
+ this.randomMode = on === true;
4174
+ }
4175
+
4176
+ /** Set what random mode randomises, from the persisted layout. @see randomScope */
4177
+ setRandomScope(scope) {
4178
+ this.randomScope = typeof scope === "string" ? scope : "both";
4179
+ }
4180
+
4181
+ /** Replace the expression pool, for a character switch. @see expressionPool */
4182
+ setExpressionPool(names) {
4183
+ this.expressionPool = Array.isArray(names) ? names : [];
4184
+ }
4185
+
4186
+ /**
4187
+ * Wear a random expression, for a random-mode idle slot.
4188
+ *
4189
+ * The pool arrives from the pet already filtered, so what random mode can pick is exactly what the expression
4190
+ * menu offers — the internal combinations exist for voice lines to borrow and are not meant to be worn alone.
4191
+ *
4192
+ * The current face is excluded from the draw so consecutive slots do not show the same expression twice. The
4193
+ * guard is on the FILTERED pool rather than an index into it, because that pool is usually shorter than the
4194
+ * model's list and an index computed from one would address the wrong entry.
4195
+ *
4196
+ * `{ user: true }` is deliberately NOT passed. That flag records a deliberate choice and pushes the drift timer
4197
+ * out of the way; this IS the drift, so claiming priority would be the drift cancelling itself.
4198
+ */
4199
+ drawIdleFace() {
4200
+ const pool = this.expressionPool;
4201
+ if (!Array.isArray(pool) || pool.length === 0) return false;
4202
+ const options = pool.filter((name) => name !== this.lastExpression);
4203
+ const from = options.length > 0 ? options : pool;
4204
+ const chosen = from[Math.floor(Math.random() * from.length)];
4205
+ if (chosen === undefined) return false;
4206
+ return this.playExpression(chosen);
3965
4207
  }
3966
4208
 
3967
4209
  /**
@@ -4577,6 +4819,114 @@ window.__ModuleLoader__.load({
4577
4819
  this.syncPhase();
4578
4820
  }
4579
4821
 
4822
+ /**
4823
+ * The expressions random mode may draw from.
4824
+ *
4825
+ * Exactly the list the expression MENU offers, built by the same two rules: the names come from the host's
4826
+ * snapshot — the loaded `Live2DModel` has no expression list of its own — and the internal combinations are
4827
+ * removed, because those exist for voice lines to borrow and are not meant to be worn on their own.
4828
+ *
4829
+ * Kept next to the menu's own filter in spirit if not in code, so the two cannot disagree about what is pickable:
4830
+ * a pool that drifted apart would let random mode show a face the user can neither choose nor reset to.
4831
+ * @see INTERNAL_EXPRESSIONS
4832
+ */
4833
+ randomFacePool() {
4834
+ const names = this.snapshot?.model?.expressions;
4835
+ if (!Array.isArray(names)) return [];
4836
+ const internal = INTERNAL_EXPRESSIONS();
4837
+ return names.filter((name) => !internal.has(expressionKey(name)));
4838
+ }
4839
+
4840
+ /**
4841
+ * The rows of the 「功能简介」 panel: what the character now on screen can actually DO.
4842
+ *
4843
+ * Every number is COUNTED from the same tables the rest of the menu uses rather than written down here, so the
4844
+ * panel cannot drift out of date — an entry added to a character's voice lines appears in this list the next time
4845
+ * it is opened, and a character with a new scope of reactions needs no edit here.
4846
+ *
4847
+ * Returned as `{ head, text }` pairs rather than as finished elements so the drawing stays in `openMenu`, next to
4848
+ * the menu it belongs to, and this stays a pure description of the character.
4849
+ */
4850
+ characterIntro() {
4851
+ const t = this.options.t;
4852
+ const snapshot = this.snapshot;
4853
+ const model = snapshot?.model;
4854
+ if (model === undefined) return [];
4855
+ const content = activeCharacterConfig();
4856
+ const rows = [];
4857
+
4858
+ /*
4859
+ * Who, and in which outfit.
4860
+ *
4861
+ * The two labels come from the same table pair the menu uses — `characterLabel` answers "who" and `modelLabel`
4862
+ * answers "wearing what" — and the outfit is only appended when it ADDS something. For 都子 the two are the
4863
+ * same string ("都子"), so showing both would read as 「都子(都子)」.
4864
+ */
4865
+ const id = snapshot?.characters?.active;
4866
+ const who = characterLabel(id);
4867
+ const outfit = modelLabel(id);
4868
+ rows.push({ head: t("pet.about.who"), text: outfit === who ? who : `${who}(${outfit})` });
4869
+
4870
+ /*
4871
+ * The motion and expression COUNTS are filtered exactly as their menus filter them: motions are the
4872
+ * centre-layer `_C` clips only, and the internal expression combinations are excluded. Counting the raw lists
4873
+ * would advertise entries the user cannot reach — the motion list includes the additive L/R poses, and the
4874
+ * expression list includes the `_nomouth` variants built for voice lines.
4875
+ */
4876
+ const motions = (model.motions ?? []).filter((name) => name.endsWith("_C")).length;
4877
+ const internal = INTERNAL_EXPRESSIONS();
4878
+ const expressions = (model.expressions ?? []).filter((name) => !internal.has(expressionKey(name))).length;
4879
+ rows.push({ head: t("pet.about.pool"), text: t("pet.about.pool.text", { motions, expressions }) });
4880
+
4881
+ /*
4882
+ * The idle drift, from the character's own content.
4883
+ *
4884
+ * `content.idleMotions` is the client-side mirror of the host's active pool — the same list `verify-phases`
4885
+ * compares — so this is the count the drift will actually draw from. The phase binding is used as a fallback
4886
+ * because it is what the running stage holds once a phase has arrived.
4887
+ */
4888
+ const idle = (content.idleMotions ?? snapshot?.animation?.idleMotions ?? []).length;
4889
+ if (idle > 0) rows.push({ head: t("pet.about.idle"), text: t("pet.about.idle.text", { count: idle }) });
4890
+
4891
+ // What she says, split the way the voice system splits it.
4892
+ const click = CLICK_REACTIONS[id] ?? CLICK_REACTIONS[id?.replace(/_(school|virtual)$/, "")];
4893
+ const clicks = (click?.normal?.length ?? 0) + (click?.rapid?.length ?? 0);
4894
+ const reactions = Object.values(content.phaseReactions ?? {}).reduce((n, entry) => n + (entry?.reactions?.length ?? 0), 0)
4895
+ + Object.values(content.phaseLines ?? {}).reduce((n, entry) => n + (entry?.lines?.length ?? 0), 0);
4896
+ const idleLines = (content.idleLines ?? []).length;
4897
+ const parts = [];
4898
+ if (click !== undefined && clicks > 0) parts.push(t("pet.about.voice.click", { count: clicks }));
4899
+ if (reactions > 0) parts.push(t("pet.about.voice.phase", { count: reactions }));
4900
+ if (idleLines > 0) parts.push(t("pet.about.voice.idle", { count: idleLines }));
4901
+ if ((content.farewellVoice ?? "") !== "") parts.push(t("pet.about.voice.farewell"));
4902
+ rows.push({ head: t("pet.about.voice"), text: parts.length > 0 ? parts.join(t("pet.about.listSep")) : t("pet.about.voice.none") });
4903
+
4904
+ /*
4905
+ * Random mode, described on its own rather than as a clause inside the controls line.
4906
+ *
4907
+ * It is the one thing in this panel that changes BEHAVIOUR rather than reporting what the character has, so
4908
+ * it needs three facts the controls line could not carry: where it lives, what the three scopes do, and that
4909
+ * it is silent. The last one is the non-obvious part — a user who turns it on and hears nothing would
4910
+ * otherwise read that as a fault.
4911
+ *
4912
+ * The CURRENT state is appended while it is on, so the panel also answers "why is she doing that", which is
4913
+ * the question someone actually opens this for.
4914
+ */
4915
+ const scopeNow = snapshot?.layout?.randomModeScope ?? "both";
4916
+ const scopeLabel = scopeNow === "motion" ? t("pet.menu.randomMotion")
4917
+ : scopeNow === "expression" ? t("pet.menu.randomExpression")
4918
+ : t("pet.menu.randomBoth");
4919
+ const randomOn = snapshot?.layout?.randomModeEnabled === true;
4920
+ rows.push({
4921
+ head: t("pet.about.random"),
4922
+ text: `${t("pet.about.random.text")}${randomOn ? t("pet.about.random.now", { scope: scopeLabel }) : ""}`
4923
+ });
4924
+
4925
+ // Interactions worth knowing about, which are the same for every character.
4926
+ rows.push({ head: t("pet.about.how"), text: t("pet.about.how.text") });
4927
+ return rows;
4928
+ }
4929
+
4580
4930
  /** Apply a snapshot's layout to the DOM (position + size + framing). */
4581
4931
  applyLayout() {
4582
4932
  const layout = this.snapshot?.layout;
@@ -4651,6 +5001,16 @@ window.__ModuleLoader__.load({
4651
5001
  */
4652
5002
  this.stage?.setBlinkEnabled(layout.blink !== false);
4653
5003
  this.stage?.setGazeEnabled(layout.gaze !== false);
5004
+ // Pushed like the two above. Read by the drift, so it must be current before the next idle slot.
5005
+ this.stage?.setRandomMode(layout.randomModeEnabled === true);
5006
+ this.stage?.setRandomScope(layout.randomModeScope);
5007
+ /*
5008
+ * Re-pushed on every layout pass, not only at construction, because the pool is per CHARACTER: switching from
5009
+ * 由乃 to 都子 swaps the expression names, and a stale pool would have random mode drawing faces the new model
5010
+ * does not have. `playExpression` refuses an unknown name, so the symptom would be a face that silently stops
5011
+ * changing rather than an error.
5012
+ */
5013
+ this.stage?.setExpressionPool(this.randomFacePool());
4654
5014
  /*
4655
5015
  * Voice level.
4656
5016
  *
@@ -5018,6 +5378,14 @@ window.__ModuleLoader__.load({
5018
5378
  // respects them; later changes go through the setters in applyLayout.
5019
5379
  blink: this.snapshot?.layout?.blink,
5020
5380
  gaze: this.snapshot?.layout?.gaze,
5381
+ randomMode: this.snapshot?.layout?.randomModeEnabled,
5382
+ randomScope: this.snapshot?.layout?.randomModeScope,
5383
+ /*
5384
+ * The pool random mode draws faces from, pushed in because the stage cannot derive it — the loaded
5385
+ * `Live2DModel` exposes no expression list, so the only source of names is the host's snapshot.
5386
+ * Filtered here rather than in the stage, because the exclusion set is per-character content.
5387
+ */
5388
+ expressionPool: this.randomFacePool(),
5021
5389
  /*
5022
5390
  * The drift decides WHICH line a slot gets — a 丸君 pose's own clip, or a
5023
5391
  * chance of an occasional line — and hands over the resolved file name. The
@@ -6612,15 +6980,21 @@ window.__ModuleLoader__.load({
6612
6980
  menu.appendChild(button);
6613
6981
  };
6614
6982
 
6615
- /** Header for a second level, with its Back button. */
6616
- const header = (title) => {
6983
+ /**
6984
+ * Header for a second level, with its Back button.
6985
+ *
6986
+ * @param title - the level's name.
6987
+ * @param backTo - the level the Back button returns to. Defaults to the root, which is where every second
6988
+ * level is reached from; only a THIRD level has to say otherwise, and 「功能简介」 under 设置 is one.
6989
+ */
6990
+ const header = (title, backTo = "root") => {
6617
6991
  const head = document.createElement("div");
6618
6992
  head.className = "dsh-l2d-menu-head";
6619
6993
  const back = document.createElement("button");
6620
6994
  back.type = "button";
6621
6995
  back.className = "dsh-l2d-menu-back";
6622
6996
  back.textContent = t("pet.menu.back");
6623
- back.addEventListener("click", () => this.renderMenuView("root"));
6997
+ back.addEventListener("click", () => this.renderMenuView(backTo));
6624
6998
  const label = document.createElement("div");
6625
6999
  label.className = "dsh-l2d-menu-title";
6626
7000
  label.textContent = title;
@@ -6672,6 +7046,55 @@ window.__ModuleLoader__.load({
6672
7046
  // as well as mapping the identifier to readable copy.
6673
7047
  item(expressionLabel(expression), () => this.stage?.playExpression(expression, { user: true }));
6674
7048
  }
7049
+ } else if (view === "random") {
7050
+ header(t("pet.menu.title.random"));
7051
+ /** Write one layout key and re-render, so the ticks track the state. */
7052
+ const write = (patch) => {
7053
+ API.layout(patch).then((result) => {
7054
+ if (this.destroyed || !result.ok) return;
7055
+ this.snapshot = { ...this.snapshot, layout: result.layout };
7056
+ this.applyLayout();
7057
+ this.refreshMenu("random");
7058
+ }, () => {});
7059
+ };
7060
+ const on = this.snapshot.layout?.randomModeEnabled === true;
7061
+ const scope = this.snapshot.layout?.randomModeScope ?? "both";
7062
+ item(`${on ? "\u2713 " : "\u3000"}${t("pet.menu.randomModeOn")}`, () => write({ randomModeEnabled: !on }));
7063
+ menu.appendChild(document.createElement("hr"));
7064
+ /*
7065
+ * The three scopes, each a radio-style tick.
7066
+ *
7067
+ * A heading rather than nothing, because the three rows are meaningless without knowing they choose
7068
+ * WHAT is randomised rather than whether it is — and the ticks stay visible while the mode is off, so
7069
+ * the scope can be set before switching it on.
7070
+ */
7071
+ const head = document.createElement("div");
7072
+ head.className = "dsh-l2d-menu-title";
7073
+ head.textContent = t("pet.menu.randomScope");
7074
+ menu.appendChild(head);
7075
+ for (const [key, label] of [["motion", t("pet.menu.randomMotion")], ["expression", t("pet.menu.randomExpression")], ["both", t("pet.menu.randomBoth")]]) {
7076
+ item(`${scope === key ? "\u2713 " : "\u3000"}${label}`, () => write({ randomModeScope: key }));
7077
+ }
7078
+ } else if (view === "about") {
7079
+ /*
7080
+ * The ONE third level: reached from 设置, so Back returns there rather than to the root.
7081
+ *
7082
+ * Read-only, so it is drawn as text rather than as buttons — a row that looks pressable and does
7083
+ * nothing is worse than plain copy. Each row is a heading plus a sentence, the same shape the
7084
+ * settings headings use.
7085
+ */
7086
+ header(t("pet.menu.title.about"), "settings");
7087
+ const intro = this.characterIntro();
7088
+ if (intro.length === 0) menu.appendChild(this.menuEmpty());
7089
+ for (const row of intro) {
7090
+ const head = document.createElement("div");
7091
+ head.className = "dsh-l2d-menu-title";
7092
+ head.textContent = row.head;
7093
+ const body = document.createElement("div");
7094
+ body.className = "dsh-l2d-menu-text";
7095
+ body.textContent = row.text;
7096
+ menu.append(head, body);
7097
+ }
6675
7098
  } else if (view === "characters") {
6676
7099
  /*
6677
7100
  * One row per CHARACTER, not per model.
@@ -6818,7 +7241,7 @@ window.__ModuleLoader__.load({
6818
7241
  // Apply straight away rather than waiting for the next poll, so the
6819
7242
  // boundary appears the moment it is switched on.
6820
7243
  this.applyLayout();
6821
- this.renderMenuView("settings");
7244
+ this.refreshMenu("settings");
6822
7245
  }, () => {});
6823
7246
  });
6824
7247
  // Blink and gaze. Both re-render the submenu so the ticks track the state.
@@ -6828,7 +7251,7 @@ window.__ModuleLoader__.load({
6828
7251
  if (this.destroyed || !result.ok) return;
6829
7252
  this.snapshot = { ...this.snapshot, layout: result.layout };
6830
7253
  this.applyLayout();
6831
- this.renderMenuView("settings");
7254
+ this.refreshMenu("settings");
6832
7255
  }, () => {});
6833
7256
  });
6834
7257
  const gaze = this.snapshot.layout?.gaze !== false;
@@ -6837,9 +7260,16 @@ window.__ModuleLoader__.load({
6837
7260
  if (this.destroyed || !result.ok) return;
6838
7261
  this.snapshot = { ...this.snapshot, layout: result.layout };
6839
7262
  this.applyLayout();
6840
- this.renderMenuView("settings");
7263
+ this.refreshMenu("settings");
6841
7264
  }, () => {});
6842
7265
  });
7266
+ /*
7267
+ * Random mode opens a level rather than toggling, because it has TWO decisions: whether it is on, and
7268
+ * what it randomises. A single checkbox could only express the first.
7269
+ */
7270
+ submenu(`${this.snapshot.layout?.randomModeEnabled === true ? "\u2713 " : "\u3000"}${t("pet.menu.randomMode")}`, "random");
7271
+ // A read-only description of whoever is on screen. @see characterIntro
7272
+ submenu(t("pet.menu.about"), "about");
6843
7273
  // Opens a submenu rather than toggling, so the current level is visible at a glance.
6844
7274
  submenu(t("pet.menu.volume"), "volume");
6845
7275
  } else {
@@ -6944,6 +7374,28 @@ window.__ModuleLoader__.load({
6944
7374
  this.menuAnchor = null;
6945
7375
  }
6946
7376
 
7377
+ /**
7378
+ * Re-render the open menu, if a menu is still open.
7379
+ *
7380
+ * Necessary because `closeMenu` sets `renderMenuView` to `undefined`, and a menu row closes the menu as part of
7381
+ * its own click — `item` runs the action and then closes. So an action that REDRAWS to show a new tick does so
7382
+ * from an asynchronous callback that fires AFTER the close, where the method no longer exists:
7383
+ *
7384
+ * TypeError: this.renderMenuView is not a function
7385
+ *
7386
+ * It was thrown by the layout toggles and swallowed by their empty rejection handler, so the ticks simply never
7387
+ * updated and nothing was reported. Guarded here rather than at each call site, because whether a menu is still
7388
+ * open is this method's own precondition.
7389
+ *
7390
+ * @param view - the level to draw.
7391
+ * @returns true when it was drawn.
7392
+ */
7393
+ refreshMenu(view) {
7394
+ if (this.menu === null || typeof this.renderMenuView !== "function") return false;
7395
+ this.renderMenuView(view);
7396
+ return true;
7397
+ }
7398
+
6947
7399
  onDocumentPointerDown(event) {
6948
7400
  if (this.menu !== null && !this.menu.contains(event.target)) this.closeMenu();
6949
7401
  }
package/lib/index.js CHANGED
@@ -589,6 +589,13 @@ export const Config = z.object({
589
589
  showFrame: z.boolean().description("显示取景框边界(虚线,仅作视觉参考,不影响拖动与缩放)。"),
590
590
  blink: z.boolean().description("自动眨眼。"),
591
591
  gaze: z.boolean().description("视线与头部追随鼠标。"),
592
+ randomModeEnabled: z.boolean()
593
+ .description("随机模式:待机时按下面选择的范围随机播放。仅待机状态生效。"),
594
+ randomModeScope: z.union([
595
+ z.const("motion").description("随机动作(表情保持不变)"),
596
+ z.const("expression").description("随机表情(动作照常)"),
597
+ z.const("both").description("随机动作+表情")
598
+ ]).description("随机模式随机的内容。"),
592
599
  voiceVolume: z.number().step(0.1).min(LAYOUT_LIMITS.voiceVolume.min).max(LAYOUT_LIMITS.voiceVolume.max)
593
600
  .description("语音音量,0 为静音(也可在桌宠菜单的「设置 → 语音音量」里调整)。"),
594
601
  name: z.string().min(1).max(24).description("桌宠显示名。")
package/lib/persist.js CHANGED
@@ -18,6 +18,15 @@ export const CROP_PRESETS = ["full", "threeQuarter", "bust"];
18
18
  /** Default framing: the whole character, so nothing is ever cut off. */
19
19
  export const DEFAULT_CROP = "full";
20
20
 
21
+ /**
22
+ * What random mode randomises on each idle slot.
23
+ *
24
+ * `motion` and `expression` are the two halves the drift can draw from; `both` pairs a fresh one of each. Kept as a closed
25
+ * list because the value is persisted and arrives from the client, so an unknown string has to be rejected in one place —
26
+ * @see normalizeLayout.
27
+ */
28
+ export const RANDOM_MODE_SCOPES = ["motion", "expression", "both"];
29
+
21
30
  /**
22
31
  * Layout defaults.
23
32
  *
@@ -56,6 +65,24 @@ export const DEFAULT_LAYOUT = {
56
65
  blink: true,
57
66
  /** Eyes and head follow the pointer anywhere on screen. */
58
67
  gaze: true,
68
+ /**
69
+ * Random mode: the idle drift draws its motion and/or its expression at random instead of following the pool alone.
70
+ *
71
+ * OFF by default, and deliberately so. The drift normally animates the BODY ONLY — her face changes only when one is
72
+ * chosen from the menu — because a face that changes on its own overwrites the user's pick. That is still the right
73
+ * default, so this is an opt-in mode rather than a change to the drift itself.
74
+ *
75
+ * While a face-randomising scope is active, a manual pick lasts only until the next slot. That is the point of the mode
76
+ * rather than a defect of it — and `resetExpression` still restores the resting face on demand.
77
+ */
78
+ randomModeEnabled: false,
79
+ /**
80
+ * What random mode randomises. @see RANDOM_MODE_SCOPES
81
+ *
82
+ * Its own field rather than one enum with an "off" member, because the two are separate decisions: turning the mode off
83
+ * and later on again should restore the scope that was chosen before, not reset it to a default.
84
+ */
85
+ randomModeScope: "both",
59
86
  /**
60
87
  * Playback volume for every spoken clip, 0..1.
61
88
  *
@@ -177,6 +204,10 @@ export function normalizeLayout(patch, base = DEFAULT_LAYOUT) {
177
204
  showFrame: typeof raw.showFrame === "boolean" ? raw.showFrame : base.showFrame,
178
205
  blink: typeof raw.blink === "boolean" ? raw.blink : base.blink,
179
206
  gaze: typeof raw.gaze === "boolean" ? raw.gaze : base.gaze,
207
+ randomModeEnabled: typeof raw.randomModeEnabled === "boolean" ? raw.randomModeEnabled : base.randomModeEnabled,
208
+ // An unknown scope degrades to the previous one, matching how `crop` is handled: the value reaches the drift, and an
209
+ // unrecognised string there would silently match no branch and leave the mode doing nothing.
210
+ randomModeScope: RANDOM_MODE_SCOPES.includes(raw.randomModeScope) ? raw.randomModeScope : base.randomModeScope,
180
211
  voiceVolume: clampNumber(raw.voiceVolume, base.voiceVolume, LAYOUT_LIMITS.voiceVolume.min, LAYOUT_LIMITS.voiceVolume.max)
181
212
  };
182
213
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pet-live2d-viola",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Live2D desktop pet for the DeepSeek Harness web GUI. One pet, three characters: 都子 (miyako_014_casual_spring_01_marukun), 薇欧拉 in casual or school uniform, and 由乃 in casual clothes or her virtual-avatar outfit — each with its own motions, expressions, voice lines, captions, idle drift, click reactions and goodbye.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",