dsh-pet-live2d-viola 1.1.0 → 1.1.1

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
@@ -1599,6 +1599,32 @@ window.__ModuleLoader__.load({
1599
1599
  "pet.menu.frame": "显示取景框边界",
1600
1600
  "pet.menu.blink": "自动眨眼",
1601
1601
  "pet.menu.gaze": "视线追随鼠标",
1602
+ "pet.menu.randomMode": "随机模式",
1603
+ "pet.menu.title.random": "随机模式",
1604
+ "pet.menu.randomModeOn": "开启随机模式",
1605
+ "pet.menu.randomScope": "随机内容",
1606
+ "pet.menu.randomMotion": "随机动作",
1607
+ "pet.menu.randomExpression": "随机表情",
1608
+ "pet.menu.randomBoth": "随机动作+表情",
1609
+ "pet.menu.about": "功能简介",
1610
+ "pet.menu.title.about": "功能简介",
1611
+ "pet.about.who": "当前人物",
1612
+ "pet.about.pool": "动作与表情",
1613
+ "pet.about.pool.text": "{motions} 个动作、{expressions} 个表情,右键菜单里可直接挑选",
1614
+ "pet.about.idle": "待机",
1615
+ "pet.about.idle.text": "每 12 秒从 {count} 个待机动作里随机换一次姿势",
1616
+ "pet.about.voice": "语音",
1617
+ "pet.about.voice.click": "点击 {count} 条",
1618
+ "pet.about.voice.phase": "阶段反应 {count} 条",
1619
+ "pet.about.voice.idle": "待机 {count} 条",
1620
+ "pet.about.voice.farewell": "下线 1 条",
1621
+ "pet.about.voice.none": "这个人物还没有语音",
1622
+ "pet.about.listSep": "、",
1623
+ "pet.about.random": "随机模式",
1624
+ "pet.about.random.text": "在「设置 → 随机模式」里开启。开启后待机时每 12 秒按所选范围重新随机,并且不出声;可选只为动作、只为表情,或两者都随机。",
1625
+ "pet.about.random.now": "当前:已开启({scope})",
1626
+ "pet.about.how": "操作",
1627
+ "pet.about.how.text": "左键拖动移动、滚轮缩放、点击说话、右键打开菜单。",
1602
1628
  "pet.menu.submenuHint": "点击进入",
1603
1629
  "pet.menu.volume": "语音音量",
1604
1630
  "pet.menu.title.volume": "语音音量(0 为静音)",
@@ -1724,6 +1750,32 @@ window.__ModuleLoader__.load({
1724
1750
  "pet.menu.frame": "Show framing boundary",
1725
1751
  "pet.menu.blink": "Auto blink",
1726
1752
  "pet.menu.gaze": "Eyes follow pointer",
1753
+ "pet.menu.randomMode": "Random mode",
1754
+ "pet.menu.title.random": "Random mode",
1755
+ "pet.menu.randomModeOn": "Enable random mode",
1756
+ "pet.menu.randomScope": "What to randomise",
1757
+ "pet.menu.randomMotion": "Random motions",
1758
+ "pet.menu.randomExpression": "Random expressions",
1759
+ "pet.menu.randomBoth": "Both motion and expression",
1760
+ "pet.menu.about": "About this character",
1761
+ "pet.menu.title.about": "About this character",
1762
+ "pet.about.who": "Currently showing",
1763
+ "pet.about.pool": "Motions and expressions",
1764
+ "pet.about.pool.text": "{motions} motions and {expressions} expressions, all pickable from the right-click menu",
1765
+ "pet.about.idle": "Idling",
1766
+ "pet.about.idle.text": "Changes pose every 12 seconds, drawn from {count} idle motions",
1767
+ "pet.about.voice": "Voice",
1768
+ "pet.about.voice.click": "{count} on click",
1769
+ "pet.about.voice.phase": "{count} phase reactions",
1770
+ "pet.about.voice.idle": "{count} idle lines",
1771
+ "pet.about.voice.farewell": "1 goodbye",
1772
+ "pet.about.voice.none": "No voice lines yet for this character",
1773
+ "pet.about.listSep": ", ",
1774
+ "pet.about.random": "Random mode",
1775
+ "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.",
1776
+ "pet.about.random.now": "Currently on ({scope})",
1777
+ "pet.about.how": "Controls",
1778
+ "pet.about.how.text": "Drag to move, scroll to zoom, click to talk, right-click for the menu.",
1727
1779
  "pet.menu.submenuHint": "open",
1728
1780
  "pet.menu.volume": "Voice volume",
1729
1781
  "pet.menu.title.volume": "Voice volume (0 = muted)",
@@ -2434,6 +2486,13 @@ window.__ModuleLoader__.load({
2434
2486
  font: 12px/1.5 system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
2435
2487
  }
2436
2488
  .dsh-l2d-menu-title { padding: 5px 8px 3px; opacity: .6; font-size: 11px; letter-spacing: .04em; }
2489
+ /*
2490
+ * A read-only line inside the menu, for the description panel.
2491
+ *
2492
+ * Wraps and stays dimmer than a button, so it reads as copy rather than as something to press — the panel has no actions,
2493
+ * and a row that looks clickable but does nothing is worse than plain text.
2494
+ */
2495
+ .dsh-l2d-menu-text { padding: 0 8px 6px; opacity: .85; font-size: 12px; line-height: 1.5; white-space: normal; }
2437
2496
  .dsh-l2d-menu button {
2438
2497
  display: block;
2439
2498
  width: 100%;
@@ -2729,6 +2788,26 @@ window.__ModuleLoader__.load({
2729
2788
  */
2730
2789
  this.blinkEnabled = options.blink !== false;
2731
2790
  this.gazeEnabled = options.gaze !== false;
2791
+ /**
2792
+ * Random mode, pushed in from the layout like the two above.
2793
+ *
2794
+ * `randomMode` says whether it is on; `randomScope` says what it randomises — motions, expressions, or both.
2795
+ * Two fields rather than one enum with an "off" member, so switching the mode off and on again restores the
2796
+ * scope that was chosen rather than resetting it.
2797
+ *
2798
+ * Read from the options rather than assigned literals so the FIRST frame already respects them; later changes
2799
+ * arrive through {@link setRandomMode}. @see randomScope
2800
+ */
2801
+ this.randomMode = options.randomMode === true;
2802
+ this.randomScope = options.randomScope ?? "both";
2803
+ /**
2804
+ * The expressions random mode may draw from, pushed in by the pet.
2805
+ *
2806
+ * It CANNOT be read from the model: `Live2DModel` exposes no `.expressions` — measured on a loaded instance,
2807
+ * the property is `undefined` — so the only list of names is the one the host sends in the snapshot, which
2808
+ * only the pet has. @see drawIdleFace
2809
+ */
2810
+ this.expressionPool = Array.isArray(options.expressionPool) ? options.expressionPool : [];
2732
2811
  /**
2733
2812
  * How the drift hands a motion to the pet so its line (voice AND caption) can be
2734
2813
  * played: `(motion) => void`.
@@ -3934,9 +4013,11 @@ window.__ModuleLoader__.load({
3934
4013
  /**
3935
4014
  * Play one drift clip for the current phase.
3936
4015
  *
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.
4016
+ * MOVES ONLY, unless RANDOM MODE is on. The face used to be randomised here unconditionally, which meant her
4017
+ * expression drifted on its own and silently overwrote whatever was picked from the menu. Her expression now
4018
+ * changes only when one is chosen — except while the user has switched random mode on, where pairing a random
4019
+ * face with the random motion is the whole feature. An explicit mode is the difference: a face that changes on
4020
+ * its own is a defect, the same behaviour behind a switch the user threw is not.
3940
4021
  *
3941
4022
  * A click animation outranks this entirely: while its hold window is open nothing is
3942
4023
  * attempted, so the startle cannot be cut short even by a drift that was already due.
@@ -3945,6 +4026,22 @@ window.__ModuleLoader__.load({
3945
4026
  if (this.clickHoldsModel()) return;
3946
4027
  const binding = this.phaseAnimation ?? PHASE_ANIMATION.idle;
3947
4028
  const motions = binding.idleMotions ?? PHASE_ANIMATION.idle.idleMotions;
4029
+ const random = this.randomModeEnabled();
4030
+ /*
4031
+ * RANDOM MODE, three scopes.
4032
+ *
4033
+ * `motion` — the motion is drawn at random (the weighted pool still applies) and the face is left alone.
4034
+ * That is what the drift already does, so this scope changes nothing except that it is now an
4035
+ * explicit choice; it exists so the menu can express it, and so a user who wants a still face
4036
+ * with lively motion has somewhere to say so.
4037
+ * `expression` — the motion follows the pool and a new face is drawn.
4038
+ * `both` — a fresh motion AND a fresh face, which is the pairing this mode was built for.
4039
+ *
4040
+ * The face is drawn BEFORE the empty-pool early return, because it does not depend on the motion pool: a
4041
+ * phase with no motions would otherwise freeze the face as well as the body, and the mode would look like it
4042
+ * worked in some states and not others.
4043
+ */
4044
+ if (random && this.randomFaces()) this.drawIdleFace();
3948
4045
  if (motions.length === 0) return;
3949
4046
  // Avoid repeating the clip already playing, so the drift reads as activity
3950
4047
  // rather than as one motion stuttering on a timer.
@@ -3961,7 +4058,87 @@ window.__ModuleLoader__.load({
3961
4058
  * because `thinking` uses a different set from every other phase and offering both would
3962
4059
  * let the two cancel each other — a second request is dropped while one is playing.
3963
4060
  */
3964
- this.offerLine(motion);
4061
+ if (!this.randomSilencesVoice(random)) this.offerLine(motion);
4062
+ }
4063
+
4064
+ /**
4065
+ * True while the user has random mode switched on AND a drift slot is allowed to use it.
4066
+ *
4067
+ * The switch lives on this object, pushed in by the pet's `applyLayout` — the stage holds no layout of its own,
4068
+ * which is why this is a field rather than a read of `options.layout`.
4069
+ *
4070
+ * Two conditions beyond the switch:
4071
+ *
4072
+ * `idlePhase` must BE `idle`, or be unset. The mode is specified for the resting state, and letting it run
4073
+ * during `listening`, `thinking`, `working` or `done` would overwrite the face each of those reactions pairs
4074
+ * with its own line — the reaction plays, and a moment later the face it chose is replaced. `undefined` is
4075
+ * accepted because the drift can run before any phase has been seen, which is the resting state.
4076
+ *
4077
+ * A click must not own the model. `playPhaseIdle` already refuses to play while one does, so this only keeps the
4078
+ * face consistent with the body it is decided alongside.
4079
+ */
4080
+ randomModeEnabled() {
4081
+ if (this.randomMode !== true) return false;
4082
+ if (this.idlePhase !== undefined && this.idlePhase !== "idle") return false;
4083
+ return !this.clickHoldsModel();
4084
+ }
4085
+
4086
+ /** True when the active scope draws a new FACE on each slot. @see randomScope */
4087
+ randomFaces() {
4088
+ return this.randomScope === "expression" || this.randomScope === "both";
4089
+ }
4090
+
4091
+ /**
4092
+ * True when the drift must stay SILENT for this slot.
4093
+ *
4094
+ * ANY active random-mode scope silences the voice. A clip carries its own caption and borrows its own face, so a
4095
+ * line interrupts the sequence the mode is running — and for the face-drawing scopes the two would also fight
4096
+ * over the expression parameter, the line winning until it ended and the drift then overwriting the face it had
4097
+ * picked. `motion` has no such conflict, but a drift that speaks in one scope and not another reads as the mode
4098
+ * being unreliable rather than as a rule, so the whole mode is quiet.
4099
+ *
4100
+ * Called with the already-computed `random` flag so the condition is evaluated once per slot.
4101
+ */
4102
+ randomSilencesVoice(random) {
4103
+ return random === true;
4104
+ }
4105
+
4106
+ /** Set random mode for the character now showing, from the persisted layout. */
4107
+ setRandomMode(on) {
4108
+ this.randomMode = on === true;
4109
+ }
4110
+
4111
+ /** Set what random mode randomises, from the persisted layout. @see randomScope */
4112
+ setRandomScope(scope) {
4113
+ this.randomScope = typeof scope === "string" ? scope : "both";
4114
+ }
4115
+
4116
+ /** Replace the expression pool, for a character switch. @see expressionPool */
4117
+ setExpressionPool(names) {
4118
+ this.expressionPool = Array.isArray(names) ? names : [];
4119
+ }
4120
+
4121
+ /**
4122
+ * Wear a random expression, for a random-mode idle slot.
4123
+ *
4124
+ * The pool arrives from the pet already filtered, so what random mode can pick is exactly what the expression
4125
+ * menu offers — the internal combinations exist for voice lines to borrow and are not meant to be worn alone.
4126
+ *
4127
+ * The current face is excluded from the draw so consecutive slots do not show the same expression twice. The
4128
+ * guard is on the FILTERED pool rather than an index into it, because that pool is usually shorter than the
4129
+ * model's list and an index computed from one would address the wrong entry.
4130
+ *
4131
+ * `{ user: true }` is deliberately NOT passed. That flag records a deliberate choice and pushes the drift timer
4132
+ * out of the way; this IS the drift, so claiming priority would be the drift cancelling itself.
4133
+ */
4134
+ drawIdleFace() {
4135
+ const pool = this.expressionPool;
4136
+ if (!Array.isArray(pool) || pool.length === 0) return false;
4137
+ const options = pool.filter((name) => name !== this.lastExpression);
4138
+ const from = options.length > 0 ? options : pool;
4139
+ const chosen = from[Math.floor(Math.random() * from.length)];
4140
+ if (chosen === undefined) return false;
4141
+ return this.playExpression(chosen);
3965
4142
  }
3966
4143
 
3967
4144
  /**
@@ -4577,6 +4754,114 @@ window.__ModuleLoader__.load({
4577
4754
  this.syncPhase();
4578
4755
  }
4579
4756
 
4757
+ /**
4758
+ * The expressions random mode may draw from.
4759
+ *
4760
+ * Exactly the list the expression MENU offers, built by the same two rules: the names come from the host's
4761
+ * snapshot — the loaded `Live2DModel` has no expression list of its own — and the internal combinations are
4762
+ * removed, because those exist for voice lines to borrow and are not meant to be worn on their own.
4763
+ *
4764
+ * Kept next to the menu's own filter in spirit if not in code, so the two cannot disagree about what is pickable:
4765
+ * a pool that drifted apart would let random mode show a face the user can neither choose nor reset to.
4766
+ * @see INTERNAL_EXPRESSIONS
4767
+ */
4768
+ randomFacePool() {
4769
+ const names = this.snapshot?.model?.expressions;
4770
+ if (!Array.isArray(names)) return [];
4771
+ const internal = INTERNAL_EXPRESSIONS();
4772
+ return names.filter((name) => !internal.has(expressionKey(name)));
4773
+ }
4774
+
4775
+ /**
4776
+ * The rows of the 「功能简介」 panel: what the character now on screen can actually DO.
4777
+ *
4778
+ * Every number is COUNTED from the same tables the rest of the menu uses rather than written down here, so the
4779
+ * panel cannot drift out of date — an entry added to a character's voice lines appears in this list the next time
4780
+ * it is opened, and a character with a new scope of reactions needs no edit here.
4781
+ *
4782
+ * Returned as `{ head, text }` pairs rather than as finished elements so the drawing stays in `openMenu`, next to
4783
+ * the menu it belongs to, and this stays a pure description of the character.
4784
+ */
4785
+ characterIntro() {
4786
+ const t = this.options.t;
4787
+ const snapshot = this.snapshot;
4788
+ const model = snapshot?.model;
4789
+ if (model === undefined) return [];
4790
+ const content = activeCharacterConfig();
4791
+ const rows = [];
4792
+
4793
+ /*
4794
+ * Who, and in which outfit.
4795
+ *
4796
+ * The two labels come from the same table pair the menu uses — `characterLabel` answers "who" and `modelLabel`
4797
+ * answers "wearing what" — and the outfit is only appended when it ADDS something. For 都子 the two are the
4798
+ * same string ("都子"), so showing both would read as 「都子(都子)」.
4799
+ */
4800
+ const id = snapshot?.characters?.active;
4801
+ const who = characterLabel(id);
4802
+ const outfit = modelLabel(id);
4803
+ rows.push({ head: t("pet.about.who"), text: outfit === who ? who : `${who}(${outfit})` });
4804
+
4805
+ /*
4806
+ * The motion and expression COUNTS are filtered exactly as their menus filter them: motions are the
4807
+ * centre-layer `_C` clips only, and the internal expression combinations are excluded. Counting the raw lists
4808
+ * would advertise entries the user cannot reach — the motion list includes the additive L/R poses, and the
4809
+ * expression list includes the `_nomouth` variants built for voice lines.
4810
+ */
4811
+ const motions = (model.motions ?? []).filter((name) => name.endsWith("_C")).length;
4812
+ const internal = INTERNAL_EXPRESSIONS();
4813
+ const expressions = (model.expressions ?? []).filter((name) => !internal.has(expressionKey(name))).length;
4814
+ rows.push({ head: t("pet.about.pool"), text: t("pet.about.pool.text", { motions, expressions }) });
4815
+
4816
+ /*
4817
+ * The idle drift, from the character's own content.
4818
+ *
4819
+ * `content.idleMotions` is the client-side mirror of the host's active pool — the same list `verify-phases`
4820
+ * compares — so this is the count the drift will actually draw from. The phase binding is used as a fallback
4821
+ * because it is what the running stage holds once a phase has arrived.
4822
+ */
4823
+ const idle = (content.idleMotions ?? snapshot?.animation?.idleMotions ?? []).length;
4824
+ if (idle > 0) rows.push({ head: t("pet.about.idle"), text: t("pet.about.idle.text", { count: idle }) });
4825
+
4826
+ // What she says, split the way the voice system splits it.
4827
+ const click = CLICK_REACTIONS[id] ?? CLICK_REACTIONS[id?.replace(/_(school|virtual)$/, "")];
4828
+ const clicks = (click?.normal?.length ?? 0) + (click?.rapid?.length ?? 0);
4829
+ const reactions = Object.values(content.phaseReactions ?? {}).reduce((n, entry) => n + (entry?.reactions?.length ?? 0), 0)
4830
+ + Object.values(content.phaseLines ?? {}).reduce((n, entry) => n + (entry?.lines?.length ?? 0), 0);
4831
+ const idleLines = (content.idleLines ?? []).length;
4832
+ const parts = [];
4833
+ if (click !== undefined && clicks > 0) parts.push(t("pet.about.voice.click", { count: clicks }));
4834
+ if (reactions > 0) parts.push(t("pet.about.voice.phase", { count: reactions }));
4835
+ if (idleLines > 0) parts.push(t("pet.about.voice.idle", { count: idleLines }));
4836
+ if ((content.farewellVoice ?? "") !== "") parts.push(t("pet.about.voice.farewell"));
4837
+ rows.push({ head: t("pet.about.voice"), text: parts.length > 0 ? parts.join(t("pet.about.listSep")) : t("pet.about.voice.none") });
4838
+
4839
+ /*
4840
+ * Random mode, described on its own rather than as a clause inside the controls line.
4841
+ *
4842
+ * It is the one thing in this panel that changes BEHAVIOUR rather than reporting what the character has, so
4843
+ * it needs three facts the controls line could not carry: where it lives, what the three scopes do, and that
4844
+ * it is silent. The last one is the non-obvious part — a user who turns it on and hears nothing would
4845
+ * otherwise read that as a fault.
4846
+ *
4847
+ * The CURRENT state is appended while it is on, so the panel also answers "why is she doing that", which is
4848
+ * the question someone actually opens this for.
4849
+ */
4850
+ const scopeNow = snapshot?.layout?.randomModeScope ?? "both";
4851
+ const scopeLabel = scopeNow === "motion" ? t("pet.menu.randomMotion")
4852
+ : scopeNow === "expression" ? t("pet.menu.randomExpression")
4853
+ : t("pet.menu.randomBoth");
4854
+ const randomOn = snapshot?.layout?.randomModeEnabled === true;
4855
+ rows.push({
4856
+ head: t("pet.about.random"),
4857
+ text: `${t("pet.about.random.text")}${randomOn ? t("pet.about.random.now", { scope: scopeLabel }) : ""}`
4858
+ });
4859
+
4860
+ // Interactions worth knowing about, which are the same for every character.
4861
+ rows.push({ head: t("pet.about.how"), text: t("pet.about.how.text") });
4862
+ return rows;
4863
+ }
4864
+
4580
4865
  /** Apply a snapshot's layout to the DOM (position + size + framing). */
4581
4866
  applyLayout() {
4582
4867
  const layout = this.snapshot?.layout;
@@ -4651,6 +4936,16 @@ window.__ModuleLoader__.load({
4651
4936
  */
4652
4937
  this.stage?.setBlinkEnabled(layout.blink !== false);
4653
4938
  this.stage?.setGazeEnabled(layout.gaze !== false);
4939
+ // Pushed like the two above. Read by the drift, so it must be current before the next idle slot.
4940
+ this.stage?.setRandomMode(layout.randomModeEnabled === true);
4941
+ this.stage?.setRandomScope(layout.randomModeScope);
4942
+ /*
4943
+ * Re-pushed on every layout pass, not only at construction, because the pool is per CHARACTER: switching from
4944
+ * 由乃 to 都子 swaps the expression names, and a stale pool would have random mode drawing faces the new model
4945
+ * does not have. `playExpression` refuses an unknown name, so the symptom would be a face that silently stops
4946
+ * changing rather than an error.
4947
+ */
4948
+ this.stage?.setExpressionPool(this.randomFacePool());
4654
4949
  /*
4655
4950
  * Voice level.
4656
4951
  *
@@ -5018,6 +5313,14 @@ window.__ModuleLoader__.load({
5018
5313
  // respects them; later changes go through the setters in applyLayout.
5019
5314
  blink: this.snapshot?.layout?.blink,
5020
5315
  gaze: this.snapshot?.layout?.gaze,
5316
+ randomMode: this.snapshot?.layout?.randomModeEnabled,
5317
+ randomScope: this.snapshot?.layout?.randomModeScope,
5318
+ /*
5319
+ * The pool random mode draws faces from, pushed in because the stage cannot derive it — the loaded
5320
+ * `Live2DModel` exposes no expression list, so the only source of names is the host's snapshot.
5321
+ * Filtered here rather than in the stage, because the exclusion set is per-character content.
5322
+ */
5323
+ expressionPool: this.randomFacePool(),
5021
5324
  /*
5022
5325
  * The drift decides WHICH line a slot gets — a 丸君 pose's own clip, or a
5023
5326
  * chance of an occasional line — and hands over the resolved file name. The
@@ -6612,15 +6915,21 @@ window.__ModuleLoader__.load({
6612
6915
  menu.appendChild(button);
6613
6916
  };
6614
6917
 
6615
- /** Header for a second level, with its Back button. */
6616
- const header = (title) => {
6918
+ /**
6919
+ * Header for a second level, with its Back button.
6920
+ *
6921
+ * @param title - the level's name.
6922
+ * @param backTo - the level the Back button returns to. Defaults to the root, which is where every second
6923
+ * level is reached from; only a THIRD level has to say otherwise, and 「功能简介」 under 设置 is one.
6924
+ */
6925
+ const header = (title, backTo = "root") => {
6617
6926
  const head = document.createElement("div");
6618
6927
  head.className = "dsh-l2d-menu-head";
6619
6928
  const back = document.createElement("button");
6620
6929
  back.type = "button";
6621
6930
  back.className = "dsh-l2d-menu-back";
6622
6931
  back.textContent = t("pet.menu.back");
6623
- back.addEventListener("click", () => this.renderMenuView("root"));
6932
+ back.addEventListener("click", () => this.renderMenuView(backTo));
6624
6933
  const label = document.createElement("div");
6625
6934
  label.className = "dsh-l2d-menu-title";
6626
6935
  label.textContent = title;
@@ -6672,6 +6981,55 @@ window.__ModuleLoader__.load({
6672
6981
  // as well as mapping the identifier to readable copy.
6673
6982
  item(expressionLabel(expression), () => this.stage?.playExpression(expression, { user: true }));
6674
6983
  }
6984
+ } else if (view === "random") {
6985
+ header(t("pet.menu.title.random"));
6986
+ /** Write one layout key and re-render, so the ticks track the state. */
6987
+ const write = (patch) => {
6988
+ API.layout(patch).then((result) => {
6989
+ if (this.destroyed || !result.ok) return;
6990
+ this.snapshot = { ...this.snapshot, layout: result.layout };
6991
+ this.applyLayout();
6992
+ this.refreshMenu("random");
6993
+ }, () => {});
6994
+ };
6995
+ const on = this.snapshot.layout?.randomModeEnabled === true;
6996
+ const scope = this.snapshot.layout?.randomModeScope ?? "both";
6997
+ item(`${on ? "\u2713 " : "\u3000"}${t("pet.menu.randomModeOn")}`, () => write({ randomModeEnabled: !on }));
6998
+ menu.appendChild(document.createElement("hr"));
6999
+ /*
7000
+ * The three scopes, each a radio-style tick.
7001
+ *
7002
+ * A heading rather than nothing, because the three rows are meaningless without knowing they choose
7003
+ * WHAT is randomised rather than whether it is — and the ticks stay visible while the mode is off, so
7004
+ * the scope can be set before switching it on.
7005
+ */
7006
+ const head = document.createElement("div");
7007
+ head.className = "dsh-l2d-menu-title";
7008
+ head.textContent = t("pet.menu.randomScope");
7009
+ menu.appendChild(head);
7010
+ for (const [key, label] of [["motion", t("pet.menu.randomMotion")], ["expression", t("pet.menu.randomExpression")], ["both", t("pet.menu.randomBoth")]]) {
7011
+ item(`${scope === key ? "\u2713 " : "\u3000"}${label}`, () => write({ randomModeScope: key }));
7012
+ }
7013
+ } else if (view === "about") {
7014
+ /*
7015
+ * The ONE third level: reached from 设置, so Back returns there rather than to the root.
7016
+ *
7017
+ * Read-only, so it is drawn as text rather than as buttons — a row that looks pressable and does
7018
+ * nothing is worse than plain copy. Each row is a heading plus a sentence, the same shape the
7019
+ * settings headings use.
7020
+ */
7021
+ header(t("pet.menu.title.about"), "settings");
7022
+ const intro = this.characterIntro();
7023
+ if (intro.length === 0) menu.appendChild(this.menuEmpty());
7024
+ for (const row of intro) {
7025
+ const head = document.createElement("div");
7026
+ head.className = "dsh-l2d-menu-title";
7027
+ head.textContent = row.head;
7028
+ const body = document.createElement("div");
7029
+ body.className = "dsh-l2d-menu-text";
7030
+ body.textContent = row.text;
7031
+ menu.append(head, body);
7032
+ }
6675
7033
  } else if (view === "characters") {
6676
7034
  /*
6677
7035
  * One row per CHARACTER, not per model.
@@ -6818,7 +7176,7 @@ window.__ModuleLoader__.load({
6818
7176
  // Apply straight away rather than waiting for the next poll, so the
6819
7177
  // boundary appears the moment it is switched on.
6820
7178
  this.applyLayout();
6821
- this.renderMenuView("settings");
7179
+ this.refreshMenu("settings");
6822
7180
  }, () => {});
6823
7181
  });
6824
7182
  // Blink and gaze. Both re-render the submenu so the ticks track the state.
@@ -6828,7 +7186,7 @@ window.__ModuleLoader__.load({
6828
7186
  if (this.destroyed || !result.ok) return;
6829
7187
  this.snapshot = { ...this.snapshot, layout: result.layout };
6830
7188
  this.applyLayout();
6831
- this.renderMenuView("settings");
7189
+ this.refreshMenu("settings");
6832
7190
  }, () => {});
6833
7191
  });
6834
7192
  const gaze = this.snapshot.layout?.gaze !== false;
@@ -6837,9 +7195,16 @@ window.__ModuleLoader__.load({
6837
7195
  if (this.destroyed || !result.ok) return;
6838
7196
  this.snapshot = { ...this.snapshot, layout: result.layout };
6839
7197
  this.applyLayout();
6840
- this.renderMenuView("settings");
7198
+ this.refreshMenu("settings");
6841
7199
  }, () => {});
6842
7200
  });
7201
+ /*
7202
+ * Random mode opens a level rather than toggling, because it has TWO decisions: whether it is on, and
7203
+ * what it randomises. A single checkbox could only express the first.
7204
+ */
7205
+ submenu(`${this.snapshot.layout?.randomModeEnabled === true ? "\u2713 " : "\u3000"}${t("pet.menu.randomMode")}`, "random");
7206
+ // A read-only description of whoever is on screen. @see characterIntro
7207
+ submenu(t("pet.menu.about"), "about");
6843
7208
  // Opens a submenu rather than toggling, so the current level is visible at a glance.
6844
7209
  submenu(t("pet.menu.volume"), "volume");
6845
7210
  } else {
@@ -6944,6 +7309,28 @@ window.__ModuleLoader__.load({
6944
7309
  this.menuAnchor = null;
6945
7310
  }
6946
7311
 
7312
+ /**
7313
+ * Re-render the open menu, if a menu is still open.
7314
+ *
7315
+ * Necessary because `closeMenu` sets `renderMenuView` to `undefined`, and a menu row closes the menu as part of
7316
+ * its own click — `item` runs the action and then closes. So an action that REDRAWS to show a new tick does so
7317
+ * from an asynchronous callback that fires AFTER the close, where the method no longer exists:
7318
+ *
7319
+ * TypeError: this.renderMenuView is not a function
7320
+ *
7321
+ * It was thrown by the layout toggles and swallowed by their empty rejection handler, so the ticks simply never
7322
+ * updated and nothing was reported. Guarded here rather than at each call site, because whether a menu is still
7323
+ * open is this method's own precondition.
7324
+ *
7325
+ * @param view - the level to draw.
7326
+ * @returns true when it was drawn.
7327
+ */
7328
+ refreshMenu(view) {
7329
+ if (this.menu === null || typeof this.renderMenuView !== "function") return false;
7330
+ this.renderMenuView(view);
7331
+ return true;
7332
+ }
7333
+
6947
7334
  onDocumentPointerDown(event) {
6948
7335
  if (this.menu !== null && !this.menu.contains(event.target)) this.closeMenu();
6949
7336
  }
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.1",
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",