dsh-plugin-vrmpet3d 0.1.0 → 0.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/lib/scene.js CHANGED
@@ -67,10 +67,20 @@
67
67
  var GAZE_YAW_LIMIT = ((80 * Math.PI) / 180) / GAZE_WEIGHT;
68
68
 
69
69
  /**
70
- * Pitch is left where it was: a head does not tip 90° forward or back without looking broken, and the
71
- * request was for the turn. Raise this the same way if a bigger nod is ever wanted.
70
+ * Automatic pitch limit, before the gaze weight. Settings expose separate actual
71
+ * upward/downward angles in degrees; both default to 60 degrees.
72
72
  */
73
- var GAZE_PITCH_LIMIT = 0.3;
73
+ var GAZE_PITCH_LIMIT = (60 * Math.PI / 180) / GAZE_WEIGHT;
74
+
75
+ function gazePitchLimits(preferences) {
76
+ var prefs = preferences || {};
77
+ function limit(key) {
78
+ return Number.isFinite(prefs[key])
79
+ ? Math.max(0, Math.min(60, prefs[key])) * Math.PI / 180 / GAZE_WEIGHT
80
+ : GAZE_PITCH_LIMIT;
81
+ }
82
+ return { up: limit('gazePitchUp'), down: limit('gazePitchDown') };
83
+ }
74
84
 
75
85
  /**
76
86
  * How far the head may turn IN TOTAL while an authored clip is driving it, in radians.
@@ -786,7 +796,8 @@
786
796
  }
787
797
 
788
798
  var yaw = Math.max(-GAZE_YAW_LIMIT, Math.min(GAZE_YAW_LIMIT, aim.yaw)) * GAZE_WEIGHT * this.gazeBlend;
789
- var pitch = Math.max(-GAZE_PITCH_LIMIT, Math.min(GAZE_PITCH_LIMIT, aim.pitch)) * GAZE_WEIGHT * this.gazeBlend;
799
+ var pitchLimits = gazePitchLimits(this.scene && this.scene.preferences);
800
+ var pitch = Math.max(-pitchLimits.down, Math.min(pitchLimits.up, aim.pitch)) * GAZE_WEIGHT * this.gazeBlend;
790
801
  var axes = rest ? this.gazeAxes(head) : null;
791
802
 
792
803
  if (layeredOnClip) {
@@ -799,8 +810,7 @@
799
810
  var budget = Math.max(0, HEAD_LAYER_LIMIT - clipTurn);
800
811
  yaw = Math.max(-budget, Math.min(budget, yaw));
801
812
  // Pitch keeps its own (smaller) limit, but never more than what is left of the budget either.
802
- pitch = Math.max(-Math.min(GAZE_PITCH_LIMIT * GAZE_WEIGHT, budget),
803
- Math.min(Math.min(GAZE_PITCH_LIMIT * GAZE_WEIGHT, budget), pitch));
813
+ pitch = Math.max(-budget, Math.min(budget, pitch));
804
814
  if (axes) {
805
815
  this._quat.setFromAxisAngle(axes.yaw, yaw);
806
816
  this._quat2.setFromAxisAngle(axes.pitch, pitch);
@@ -1669,6 +1679,9 @@
1669
1679
  if (this.disposed) return;
1670
1680
  var w = Math.max(1, Math.round(width));
1671
1681
  var h = Math.max(1, Math.round(height));
1682
+ // Layout works in fractional CSS pixels. Reapplying the same rounded size clears
1683
+ // the framebuffer between rAFs when a settings reply arrives during an orbit.
1684
+ if (this.width === w && this.height === h) return;
1672
1685
  this.renderer.setSize(w, h, false);
1673
1686
  this.camera.aspect = w / h;
1674
1687
  this.camera.updateProjectionMatrix();
@@ -1755,6 +1768,8 @@
1755
1768
 
1756
1769
  /** Replace the current avatar with a hardware-ready one. */
1757
1770
  Scene.prototype.installAvatar = function (avatar, root) {
1771
+ (this.toonMaterials || []).forEach(function (entry) { if (entry.plain) entry.plain.dispose(); });
1772
+ this.toonMaterials = null;
1758
1773
  // Stop before the old avatar is replaced: stopping is what undoes the clip's
1759
1774
  // pose, and it has to happen while the old model is still installed. Every
1760
1775
  // clip is then dropped, because a clip is fitted to one model — the cached
@@ -1779,6 +1794,7 @@
1779
1794
  this.frameAvatar(root);
1780
1795
  this.stage.add(root);
1781
1796
  this.avatar = avatar;
1797
+ this.captureToonMaterials(root);
1782
1798
  this.kind = avatar.kind;
1783
1799
  /**
1784
1800
  * …and the collider switch, applied to the avatar that was just adopted.
@@ -1844,6 +1860,7 @@
1844
1860
  * model is loaded, so on a fresh pet that call finds nothing to snapshot and the first sweep would have
1845
1861
  * had no gravity to lean against (measured: `windState().joints === 0` on a real model with two chains).
1846
1862
  */
1863
+ this.applySprings(this.preferences);
1847
1864
  this.captureSpringBase();
1848
1865
  this.expressionBehaviour = { blink: 0, blinkExpression: 'blink', emotion: null, emotionWeight: 0 };
1849
1866
  this.expressionState = { nextBlinkAt: null, blinkUntil: 0, nextIdleEmotionAt: null, emotionUntil: 0 };
@@ -1852,6 +1869,73 @@
1852
1869
  this.motion.setGazeIdle(this.preferences.gazeIdleSeconds);
1853
1870
  this.updateShadowPlane();
1854
1871
  this.refitCamera();
1872
+ this.applyToonPreferences();
1873
+ };
1874
+
1875
+ /** Keep the loaded materials (and expression bindings) intact so the switch is reversible. */
1876
+ Scene.prototype.captureToonMaterials = function (root) {
1877
+ var self = this;
1878
+ this.toonMeshes = [];
1879
+ this.toonMaterials = [];
1880
+ var seen = new Map();
1881
+ root.traverse(function (node) {
1882
+ if (!node.isMesh || !node.material) return;
1883
+ var originals = Array.isArray(node.material) ? node.material : [node.material];
1884
+ var entries = originals.map(function (material) {
1885
+ if (!material.isMToonMaterial) return null;
1886
+ if (seen.has(material)) return seen.get(material);
1887
+ var entry = { material: material, toony: material.shadingToonyFactor, shift: material.shadingShiftFactor,
1888
+ shade: material.shadeColorFactor.clone(), width: material.outlineWidthFactor, plain: null };
1889
+ seen.set(material, entry);
1890
+ self.toonMaterials.push(entry);
1891
+ return entry;
1892
+ });
1893
+ if (entries.some(Boolean)) self.toonMeshes.push({ node: node, original: node.material, entries: entries, originals: originals });
1894
+ });
1895
+ };
1896
+
1897
+ Scene.prototype.applyToonPreferences = function () {
1898
+ var self = this;
1899
+ var id = this.currentModelId || this.preferences.modelId;
1900
+ var prefs = (this.preferences.toon || {})[id] || {};
1901
+ var disabled = prefs.enabled === false;
1902
+ (this.toonMaterials || []).forEach(function (entry) {
1903
+ var material = entry.material;
1904
+ material.shadingToonyFactor = prefs.shadingToonyFactor === undefined ? entry.toony : prefs.shadingToonyFactor;
1905
+ material.shadingShiftFactor = prefs.shadingShiftFactor === undefined ? entry.shift : prefs.shadingShiftFactor;
1906
+ material.shadeColorFactor.copy(entry.shade);
1907
+ if (prefs.shadeStrength !== undefined) material.shadeColorFactor.lerp(material.color, 1 - prefs.shadeStrength);
1908
+ material.outlineWidthFactor = entry.width * (prefs.outlineScale === undefined ? 1 : prefs.outlineScale);
1909
+ if (disabled && !entry.plain) {
1910
+ var plain = new self.THREE.MeshStandardMaterial();
1911
+ ['map', 'alphaMap', 'alphaTest', 'transparent', 'opacity', 'side', 'depthWrite', 'depthTest',
1912
+ 'normalMap', 'normalScale', 'emissive', 'emissiveMap', 'emissiveIntensity', 'color'].forEach(function (key) {
1913
+ if (material[key] === undefined) return;
1914
+ if (plain[key] && typeof plain[key].copy === 'function') plain[key].copy(material[key]);
1915
+ else plain[key] = material[key];
1916
+ });
1917
+ plain.visible = !material.isOutline;
1918
+ entry.plain = plain;
1919
+ }
1920
+ });
1921
+ (this.toonMeshes || []).forEach(function (mesh) {
1922
+ mesh.node.material = disabled
1923
+ ? (Array.isArray(mesh.original) ? mesh.originals.map(function (m, i) { return mesh.entries[i] ? mesh.entries[i].plain : m; })
1924
+ : mesh.entries[0].plain)
1925
+ : mesh.original;
1926
+ });
1927
+ this.toonDisabled = disabled;
1928
+ };
1929
+
1930
+ Scene.prototype.syncPlainMaterials = function () {
1931
+ if (!this.toonDisabled) return;
1932
+ (this.toonMaterials || []).forEach(function (entry) {
1933
+ if (!entry.plain) return;
1934
+ // Expressions still target the authored MToon material, including UV animation.
1935
+ entry.plain.color.copy(entry.material.color);
1936
+ entry.plain.emissive.copy(entry.material.emissive);
1937
+ entry.plain.opacity = entry.material.opacity;
1938
+ });
1855
1939
  };
1856
1940
 
1857
1941
  /** A soft ground blob, so the pet does not appear to float. */
@@ -3380,7 +3464,8 @@
3380
3464
  var x = Math.max(-1, Math.min(1, aim.x));
3381
3465
  var y = Math.max(-1, Math.min(1, aim.y));
3382
3466
  // Screen y grows downward, pitch grows upward.
3383
- return { yaw: x * GAZE_YAW_LIMIT, pitch: -y * GAZE_PITCH_LIMIT };
3467
+ var limits = gazePitchLimits(this.preferences);
3468
+ return { yaw: x * GAZE_YAW_LIMIT, pitch: -y * (y < 0 ? limits.up : limits.down) };
3384
3469
  };
3385
3470
 
3386
3471
  /** Advance one frame. `dt` is in seconds. */
@@ -3437,6 +3522,7 @@
3437
3522
  this.advanceExpressions(dt);
3438
3523
  this.applyExpressions(this.preferences);
3439
3524
 
3525
+ this.syncPlainMaterials();
3440
3526
  this.renderer.render(this.scene, this.camera);
3441
3527
  this.frame += 1;
3442
3528
  };
@@ -3448,6 +3534,7 @@
3448
3534
  Scene.prototype.applyPreferences = function (preferences) {
3449
3535
  var raw = preferences && typeof preferences === 'object' ? preferences : {};
3450
3536
  this.preferences = this.core.cleanPreferences(preferences);
3537
+ this.applyToonPreferences();
3451
3538
  // A preferences push only carries `motions` when the caller meant to change
3452
3539
  // it. cleanPreferences always produces a (possibly empty) key, so adopting it
3453
3540
  // unconditionally would silently wipe a map set through setMotionMap.
@@ -3641,6 +3728,40 @@
3641
3728
  };
3642
3729
  };
3643
3730
 
3731
+ /** The rendered eyes' midpoint, in canvas CSS pixels, independent of framing and camera orbit. */
3732
+ Scene.prototype.gazeOrigin = function () {
3733
+ var avatar = this.avatar;
3734
+ if (!avatar || !this.THREE || !this.camera) return null;
3735
+ var point = this._gazeOriginVec || (this._gazeOriginVec = new this.THREE.Vector3());
3736
+ var other = this._gazeOtherEyeVec || (this._gazeOtherEyeVec = new this.THREE.Vector3());
3737
+ var humanoid = avatar.vrm && avatar.vrm.humanoid;
3738
+ var left = humanoid && typeof humanoid.getRawBoneNode === 'function' ? humanoid.getRawBoneNode('leftEye') : null;
3739
+ var right = humanoid && typeof humanoid.getRawBoneNode === 'function' ? humanoid.getRawBoneNode('rightEye') : null;
3740
+ left = left || (avatar.bones && avatar.bones.leftEye);
3741
+ right = right || (avatar.bones && avatar.bones.rightEye);
3742
+ var source;
3743
+ if (left && right) {
3744
+ left.getWorldPosition(point); right.getWorldPosition(other);
3745
+ point.add(other).multiplyScalar(0.5);
3746
+ source = 'eyes';
3747
+ } else if (avatar.vrm && avatar.vrm.lookAt && typeof avatar.vrm.lookAt.getLookAtWorldPosition === 'function') {
3748
+ // The loader has already converted VRM 0/1 coordinate conventions for this offset.
3749
+ if (avatar.root) avatar.root.updateWorldMatrix(true, true);
3750
+ avatar.vrm.lookAt.getLookAtWorldPosition(point);
3751
+ source = 'lookAt';
3752
+ } else {
3753
+ var head = gazeNodeFor(avatar);
3754
+ if (!head) return null;
3755
+ head.getWorldPosition(point);
3756
+ source = 'head';
3757
+ }
3758
+ this.camera.updateMatrixWorld(true);
3759
+ var projected = this.projectToCanvas(point);
3760
+ if (!projected || projected.z < -1 || projected.z > 1) return null;
3761
+ projected.source = source;
3762
+ return projected;
3763
+ };
3764
+
3644
3765
  /**
3645
3766
  * Apply the draught to the chains, decay it, and keep it LOCAL.
3646
3767
  *
@@ -3865,6 +3986,10 @@
3865
3986
  */
3866
3987
  Scene.prototype.setSpringDefaults = function (defaults) {
3867
3988
  this.springDefaults = defaults && typeof defaults === 'object' ? defaults : null;
3989
+ // Catalog defaults can arrive after the avatar and preferences on either surface.
3990
+ // Apply them now; waiting for another preference edit makes a reload appear to fix physics.
3991
+ this.applySprings(this.preferences);
3992
+ this.captureSpringBase();
3868
3993
  };
3869
3994
 
3870
3995
  /**
@@ -4931,6 +5056,8 @@
4931
5056
 
4932
5057
  Scene.prototype.dispose = function () {
4933
5058
  if (this.disposed) return;
5059
+ (this.toonMeshes || []).forEach(function (mesh) { mesh.node.material = mesh.original; });
5060
+ (this.toonMaterials || []).forEach(function (entry) { if (entry.plain) entry.plain.dispose(); });
4934
5061
  this.disposed = true;
4935
5062
  // Stop and uncache every mixer before the renderer goes: a mixer holds a
4936
5063
  // binding table that references the model's bones, so leaving them behind
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>vrmpet3d · 初次安装引导</title>
6
+ <title data-i18n="pageTitle">vrmpet3d · 初次安装引导</title>
7
7
  <meta name="color-scheme" content="dark light">
8
8
  <!--
9
9
  The first-run guide: its screens and a finish, on its own route (`/vrmpet3d/setup`). Which of the six the flow
@@ -49,8 +49,8 @@
49
49
  <body data-page="setup">
50
50
  <header class="page-header">
51
51
  <div class="eyebrow">VRMPET3D · SETUP</div>
52
- <h1>初次安装引导</h1>
53
- <div id="status" role="status" aria-live="polite">正在读取…</div>
52
+ <h1 data-i18n="guideTitle">初次安装引导</h1>
53
+ <div id="status" role="status" aria-live="polite" data-i18n="reading">正在读取…</div>
54
54
  </header>
55
55
 
56
56
  <!--
@@ -62,29 +62,44 @@
62
62
  step that is not there and a gap where one never was. The words live here and the policy in the attributes; both
63
63
  files are read together by `verify-desktop.mjs`.
64
64
  -->
65
- <ol class="setup-steps" id="setup-steps" aria-label="引导进度">
66
- <li data-screen="setup-0">欢迎</li>
67
- <li data-screen="setup-1">偏好模型</li>
68
- <li data-screen="setup-2" data-desktop-only="1">桌宠界面</li>
69
- <li data-screen="setup-3">AI 碎碎念</li>
70
- <li data-screen="setup-4" data-hidden="1">模型动作</li>
71
- <li data-screen="setup-5">完成</li>
65
+ <ol class="setup-steps" id="setup-steps" aria-label="引导进度" data-i18n-aria="stepsAria">
66
+ <li data-screen="setup-0" data-i18n="stepWelcome">欢迎</li>
67
+ <li data-screen="setup-1" data-i18n="stepModel">偏好模型</li>
68
+ <li data-screen="setup-2" data-desktop-only="1" data-i18n="stepSurface">桌宠界面</li>
69
+ <li data-screen="setup-3" data-i18n="stepSpeech">AI 碎碎念</li>
70
+ <li data-screen="setup-4" data-hidden="1" data-i18n="stepMotions">模型动作</li>
71
+ <li data-screen="setup-5" data-i18n="stepDone">完成</li>
72
72
  </ol>
73
73
 
74
74
  <main>
75
75
  <!-- ① 欢迎 -->
76
76
  <section class="settings-section setup-screen" id="setup-0">
77
- <h2>欢迎,也先说清楚风险</h2>
77
+ <h2 data-i18n="welcomeTitle">欢迎,也先说清楚风险</h2>
78
78
  <div class="card">
79
- <p>这是一个<strong>早期版本</strong>的插件,作者也是第一次开发 DSH 插件。它<strong>还没有经过系统性的验证</strong>,
80
- 可能存在 bug,也可能带来性能下降;设置页面的排版也可能比较混乱。</p>
81
- <p class="setup-warning">如果你在使用中遇到任何性能问题,请<strong>第一时间禁用这个插件</strong>,再慢慢排查。</p>
79
+ <!--
80
+ 界面语言 sits ABOVE the risk note (operator, 2026-10-15: 「在初始安装引导页的第一页欢迎页面中加上语言选择」):
81
+ a reader who cannot read the paragraphs picks the language before reading them. It is the one choice on this
82
+ page that is written and applied the moment it changes rather than at 完成 — see `wireChoices` in setup-page.js.
83
+
84
+ The paragraphs below are single-line on purpose: `core.applyI18n` rewrites their whole `innerHTML`, and the
85
+ markup's own text is the no-JS fallback a parity check compares against the zh half of the table.
86
+ -->
87
+ <div class="row">
88
+ <label class="k" for="setup-language" data-i18n="language">界面语言</label>
89
+ <select id="setup-language">
90
+ <option value="auto" data-i18n="languageAuto">自动</option>
91
+ <option value="zh" data-i18n="languageZh">中文</option>
92
+ <option value="en" data-i18n="languageEn">English</option>
93
+ </select>
94
+ </div>
95
+ <p data-i18n-html="welcomeRisk">这是一个<strong>早期版本</strong>的插件,作者也是第一次开发 DSH 插件。它<strong>还没有经过系统性的验证</strong>,可能存在 bug,也可能带来性能下降;设置页面的排版也可能比较混乱。</p>
96
+ <p class="setup-warning" data-i18n-html="welcomeWarning">如果你在使用中遇到任何性能问题,请<strong>第一时间禁用这个插件</strong>,再慢慢排查。</p>
82
97
  </div>
83
98
  </section>
84
99
 
85
100
  <!-- ② 偏好模型 -->
86
101
  <section class="settings-section setup-screen" id="setup-1" hidden>
87
- <h2>偏好模型</h2>
102
+ <h2 data-i18n="stepModel">偏好模型</h2>
88
103
  <!--
89
104
  The picker itself is `lib/model-picker.js`, mounted here and mounted again on the 切换模型 page
90
105
  (`/vrmpet3d/picker`, opened from either pet's right-click menu). The operator, 2026-10-13:
@@ -100,13 +115,13 @@
100
115
 
101
116
  <!-- ③ 桌宠界面 -->
102
117
  <section class="settings-section setup-screen" id="setup-2" hidden>
103
- <h2>桌宠出现在哪里</h2>
118
+ <h2 data-i18n="surfaceTitle">桌宠出现在哪里</h2>
104
119
  <div class="card">
105
120
  <div class="setup-choice" id="setup-surface">
106
121
  <label><input type="radio" name="setup-surface" value="auto">
107
- <strong>自动</strong><span class="note">Desktop端自动开启独立桌宠</span></label>
122
+ <strong data-i18n="surfaceAuto">自动</strong><span class="note" data-i18n="surfaceAutoNote">Desktop端自动开启独立桌宠</span></label>
108
123
  <label><input type="radio" name="setup-surface" value="app">
109
- <strong>只在内置桌宠</strong></label>
124
+ <strong data-i18n="surfaceApp">只在内置桌宠</strong></label>
110
125
  </div>
111
126
  <p class="hint" id="setup-surface-note"></p>
112
127
  </div>
@@ -119,36 +134,33 @@
119
134
  所以这里说的是**本次真正会用的那个目录**,不是一句泛泛的话。
120
135
  -->
121
136
  <div class="card">
122
- <p><strong>浮窗会借用一份 DSH 自带的运行时。</strong>独立浮窗需要无边框、置顶、点击穿透的系统窗口,这只有 Electron 能给,而插件
123
- 不自带(那会多出两百多兆)。所以启用浮窗时,启动器会在 <strong>DSH 安装目录所在的分区</strong>里建一个运行时目录,用
124
- <strong>硬链接</strong>指向 DSH 自己的那批客户端文件 —— 于是它看起来就是一份没改动过的 DSH。</p>
125
- <p><strong>硬链接与 DSH 共享同一份数据,不额外占盘</strong>(新增的只有一个小 app 壳),并且它<strong>必须与 DSH 同盘</strong>:
126
- 硬链接不能跨卷。删掉这个目录是安全的,下次启用浮窗会重新建一份。</p>
137
+ <p data-i18n-html="runtimeBorrow"><strong>浮窗会借用一份 DSH 自带的运行时。</strong>独立浮窗需要无边框、置顶、点击穿透的系统窗口,这只有 Electron 能给,而插件不自带(那会多出两百多兆)。所以启用浮窗时,启动器会在 <strong>DSH 安装目录所在的分区</strong>里建一个运行时目录,用 <strong>硬链接</strong>指向 DSH 自己的那批客户端文件 —— 于是它看起来就是一份没改动过的 DSH。</p>
138
+ <p data-i18n-html="runtimeLinks"><strong>硬链接与 DSH 共享同一份数据,不额外占盘</strong>(新增的只有一个小 app 壳),并且它<strong>必须与 DSH 同盘</strong>:硬链接不能跨卷。删掉这个目录是安全的,下次启用浮窗会重新建一份。</p>
127
139
  <p class="hint" id="setup-runtime-path"></p>
128
140
  </div>
129
141
  </section>
130
142
 
131
143
  <!-- ④ AI 碎碎念 -->
132
144
  <section class="settings-section setup-screen" id="setup-3" hidden>
133
- <h2>AI 碎碎念</h2>
145
+ <h2 data-i18n="speechTitle">AI 碎碎念</h2>
134
146
  <div class="card">
135
147
  <div class="row">
136
- <label class="k" for="setup-speech-enabled">开启碎碎念</label>
148
+ <label class="k" for="setup-speech-enabled" data-i18n="speechEnabled">开启碎碎念</label>
137
149
  <input type="checkbox" id="setup-speech-enabled">
138
150
  </div>
139
151
  <div class="setup-choice" id="setup-speech-source">
140
152
  <label><input type="radio" name="setup-speech-source" value="builtin">
141
- <strong>内置台词</strong><span class="note">内置台词基本已经够用</span></label>
153
+ <strong data-i18n="speechBuiltin">内置台词</strong><span class="note" data-i18n="speechBuiltinNote">内置台词基本已经够用</span></label>
142
154
  <label><input type="radio" name="setup-speech-source" value="ai">
143
- <strong>AI 生成</strong></label>
155
+ <strong data-i18n="speechAi">AI 生成</strong></label>
144
156
  </div>
145
157
  <div class="row" id="setup-ai-row">
146
- <label class="k" for="setup-ai-provider">AI 来源</label>
158
+ <label class="k" for="setup-ai-provider" data-i18n="speechProvider">AI 来源</label>
147
159
  <select id="setup-ai-provider"></select>
148
160
  <select id="setup-ai-model"></select>
149
161
  </div>
150
162
  <!-- Shown with the row above: the AI's own words are configured on the OTHER page, in the same 碎碎念 section. -->
151
- <p class="hint" id="setup-ai-note" hidden>人格信息和不同状态的提示词,都在 通用设置 → 碎碎念 里设置。</p>
163
+ <p class="hint" id="setup-ai-note" hidden data-i18n="speechAiNote">人格信息和不同状态的提示词,都在 通用设置 → 碎碎念 里设置。</p>
152
164
  <p class="hint" id="setup-speech-note"></p>
153
165
  </div>
154
166
  <!--
@@ -161,12 +173,8 @@
161
173
  is about the SOFTWARE, this one about what the pet SAYS and what the plugin touches.
162
174
  -->
163
175
  <div class="card" id="setup-speech-disclaimer">
164
- <p><strong>碎碎念只是玩笑,请勿当真。</strong>这些话要么来自内置台词,要么由你配置的模型即兴生成,都属于角色扮演:
165
- 不是宠物的真实判断,也不构成任何建议。</p>
166
- <p>插件<strong>不会对你的项目或文件做任何实际操作</strong>(不执行命令、不改动代码),只读取会话的<strong>运行状态</strong>
167
- (进行中 / 等待确认 / 完成 等)来决定表情和动作,会话文字、工具参数与附件都不会被它读取;你导入的模型或动作,
168
- 只会被复制到插件自己的目录里。<strong>它也不会干扰 DSH 本身的运行</strong>,且不带任何运行时依赖。不过在配置较差的
169
- 机器上,3D 渲染与弹簧骨物理可能带来轻微的性能影响 —— 关掉桌宠,或把尺寸调小一些,就能缓解。</p>
176
+ <p data-i18n-html="speechDisclaimerA"><strong>碎碎念只是玩笑,请勿当真。</strong>这些话要么来自内置台词,要么由你配置的模型即兴生成,都属于角色扮演:不是宠物的真实判断,也不构成任何建议。</p>
177
+ <p data-i18n-html="speechDisclaimerB">插件<strong>不会对你的项目或文件做任何实际操作</strong>(不执行命令、不改动代码),只读取会话的<strong>运行状态</strong>(进行中 / 等待确认 / 完成 等)来决定表情和动作,会话文字、工具参数与附件都不会被它读取;你导入的模型或动作,只会被复制到插件自己的目录里。<strong>它也不会干扰 DSH 本身的运行</strong>,且不带任何运行时依赖。不过在配置较差的机器上,3D 渲染与弹簧骨物理可能带来轻微的性能影响 —— 关掉桌宠,或把尺寸调小一些,就能缓解。</p>
170
178
  </div>
171
179
  </section>
172
180
 
@@ -177,17 +185,17 @@
177
185
  where the same import lives for anyone who does want it.
178
186
  -->
179
187
  <section class="settings-section setup-screen" id="setup-4" hidden>
180
- <h2>模型动作</h2>
188
+ <h2 data-i18n="motionsTitle">模型动作</h2>
181
189
  <div class="card">
182
190
  <div class="setup-actions">
183
- <button id="setup-motion-pick" class="primary">选择动作文件夹…</button>
184
- <button id="setup-motion-manual" class="ghost">手动输入路径</button>
191
+ <button id="setup-motion-pick" class="primary" data-i18n="motionsPick">选择动作文件夹…</button>
192
+ <button id="setup-motion-manual" class="ghost" data-i18n="motionsManual">手动输入路径</button>
185
193
  <span class="note" id="setup-motion-count"></span>
186
194
  </div>
187
195
  <div class="row" id="setup-motion-manual-row" hidden>
188
- <label class="k" for="setup-motion-path">文件夹路径</label>
196
+ <label class="k" for="setup-motion-path" data-i18n="motionsPath">文件夹路径</label>
189
197
  <input type="text" id="setup-motion-path" class="grow" placeholder="D:\motions\my-pack">
190
- <button id="setup-motion-add" class="ghost">导入</button>
198
+ <button id="setup-motion-add" class="ghost" data-i18n="motionsAdd">导入</button>
191
199
  </div>
192
200
  <div class="setup-list" id="setup-motion-list"></div>
193
201
  <p class="hint" id="setup-motion-note"></p>
@@ -197,12 +205,12 @@
197
205
 
198
206
  <!-- 完成 -->
199
207
  <section class="settings-section setup-screen" id="setup-5" hidden>
200
- <h2>完成</h2>
208
+ <h2 data-i18n="doneTitle">完成</h2>
201
209
  <div class="card">
202
- <p class="setup-done">配置已保存。</p>
210
+ <p class="setup-done" data-i18n="doneSaved">配置已保存。</p>
203
211
  <div class="setup-actions">
204
- <button id="setup-finish" class="primary">完成</button>
205
- <button id="setup-close" class="ghost" hidden>关闭窗口</button>
212
+ <button id="setup-finish" class="primary" data-i18n="finishButton">完成</button>
213
+ <button id="setup-close" class="ghost" hidden data-i18n="closeWindow">关闭窗口</button>
206
214
  </div>
207
215
  <p class="hint" id="setup-finish-note"></p>
208
216
  </div>
@@ -210,16 +218,27 @@
210
218
  </main>
211
219
 
212
220
  <footer class="setup-nav">
213
- <button id="setup-later" class="ghost">稍后配置</button>
221
+ <button id="setup-later" class="ghost" data-i18n="later">稍后配置</button>
214
222
  <div class="setup-nav-right">
215
- <button id="setup-back" class="ghost">上一步</button>
216
- <button id="setup-next" class="primary">下一步</button>
223
+ <button id="setup-back" class="ghost" data-i18n="back">上一步</button>
224
+ <button id="setup-next" class="primary" data-i18n="next">下一步</button>
217
225
  </div>
218
226
  </footer>
219
227
 
220
- <!-- The same watchdog the two settings pages carry: a page that cannot initialise says so rather than sitting blank. -->
228
+ <!--
229
+ The same watchdog the two settings pages carry: a page that cannot initialise says so rather than sitting blank.
230
+
231
+ It runs BEFORE core.js, so it cannot use the shared table or `resolveLanguage` — hence the two-line map and picker
232
+ below. A stalled page is exactly the page whose reader needs a sentence they can read, and this is the only place
233
+ on the page where English and Chinese both have to fit in one script with no dependency.
234
+ -->
221
235
  <script>
222
236
  (function () {
237
+ var STUCK = {
238
+ zh: '初始化卡住:脚本已加载但没有完成初始化(看看窗口的控制台,或宿主是否提供 /vrmpet3d/setup-state)。',
239
+ en: 'Initialisation stalled: the script loaded but never finished (check the window console, or whether the host serves /vrmpet3d/setup-state).',
240
+ };
241
+ var language = /^(zh|cn|chinese)/i.test(navigator.language || '') ? 'zh' : 'en';
223
242
  function show(text) {
224
243
  var node = document.getElementById('status');
225
244
  if (node) { node.textContent = text; node.className = 'warn'; }
@@ -227,7 +246,7 @@
227
246
  window.setTimeout(function () {
228
247
  var node = document.getElementById('status');
229
248
  if (node && !node.getAttribute('data-ready')) {
230
- show('初始化卡住:脚本已加载但没有完成初始化(看看窗口的控制台,或宿主是否提供 /vrmpet3d/setup-state)。');
249
+ show(STUCK[language]);
231
250
  }
232
251
  }, 3000);
233
252
  })();