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.
@@ -113,7 +113,7 @@
113
113
  reloadModel: 'Reload model',
114
114
  dance: 'Dance for me',
115
115
  modelSettings: "This model's parameters",
116
- surfaceWindow: 'Move to the floating window',
116
+ surfaceWindow: 'Switch to an independent pet',
117
117
  surfaceApp: 'Move back inside the app',
118
118
  surfaceOff: 'disabled while debug mode is on',
119
119
  gravity: 'Gravity',
@@ -141,7 +141,7 @@
141
141
  reloadModel: '重载模型',
142
142
  dance: '跳支舞',
143
143
  modelSettings: '当前模型参数设置',
144
- surfaceWindow: '切换回窗口外',
144
+ surfaceWindow: '切换为独立宠物',
145
145
  surfaceApp: '切换回窗口内',
146
146
  surfaceOff: '调试模式下不生效',
147
147
  gravity: '重力',
@@ -236,6 +236,7 @@
236
236
  var onChoose = typeof options.onChoose === 'function' ? options.onChoose : function () {};
237
237
  var toSurface = options.toSurface === 'app' ? 'app' : 'window';
238
238
  var surfaceAllowed = options.surfaceAllowed !== false;
239
+ var danceState = 'resting';
239
240
  /** Whether this surface offers the 切换 row at all. See the `showSurface` option. */
240
241
  var showSurface = options.showSurface !== false;
241
242
 
@@ -262,6 +263,8 @@
262
263
 
263
264
  /** The label for one id, from the state this menu was built with. */
264
265
  function labelFor(id) {
266
+ if (id === 'dance') return danceState === 'dancing' ? (options.language === 'en' ? 'Another dance' : '换一支舞')
267
+ : (['resting', 'offline'].indexOf(danceState) === -1 ? (options.language === 'en' ? 'The pet is busy…' : '宠物目前很忙…') : text.dance);
265
268
  if (id === 'surface') return toSurface === 'app' ? text.surfaceApp : text.surfaceWindow;
266
269
  if (id === 'closePet') return toSurface === 'app' ? text.closePetWindow : text.closePetApp;
267
270
  return text[id] || id;
@@ -275,7 +278,8 @@
275
278
  ITEMS.forEach(function (id) {
276
279
  // The 切换 row is the one entry a surface may omit — the window's 关闭独立宠物 already does its job.
277
280
  if (id === 'surface' && !showSurface) return;
278
- var disabled = id === 'surface' && surfaceAllowed !== true;
281
+ var disabled = (id === 'surface' && surfaceAllowed !== true)
282
+ || (id === 'dance' && ['resting', 'offline', 'dancing'].indexOf(danceState) === -1);
279
283
  var toggle = TOGGLES.indexOf(id) !== -1;
280
284
  var checked = toggle && checkedState[id] === true;
281
285
  var item = doc.createElement('button');
@@ -300,7 +304,7 @@
300
304
  * waiting to happen ("the switch does nothing"). The reason is the operator's own qualifier —
301
305
  * 非debug模式生效 — spelled out for the mode that turns it off.
302
306
  */
303
- item.title = text.surfaceOff;
307
+ item.title = id === 'dance' ? label : text.surfaceOff;
304
308
  } else {
305
309
  if (toggle && !checked) item.title = text.gravityOff;
306
310
  item.addEventListener('click', function (event) {
@@ -456,6 +460,12 @@
456
460
  setSurface: function (next) { toSurface = next === 'app' ? 'app' : 'window'; },
457
461
  /** Whether that row may be used at all (false in debug mode). */
458
462
  setSurfaceAllowed: function (allowed) { surfaceAllowed = allowed !== false; },
463
+ setSurfaceVisible: function (visible) { showSurface = visible === true; },
464
+ setDanceState: function (state) {
465
+ if (danceState === state) return;
466
+ danceState = state;
467
+ if (!element.hidden) render();
468
+ },
459
469
  /**
460
470
  * Which way a SWITCH row is thrown, read at OPEN time by the surface that owns the preference.
461
471
  *
@@ -75,10 +75,20 @@
75
75
  var GAZE_YAW_LIMIT = ((80 * Math.PI) / 180) / GAZE_WEIGHT;
76
76
 
77
77
  /**
78
- * Pitch is left where it was: a head does not tip 90° forward or back without looking broken, and the
79
- * request was for the turn. Raise this the same way if a bigger nod is ever wanted.
78
+ * Automatic pitch limit, before the gaze weight. Settings expose separate actual
79
+ * upward/downward angles in degrees; both default to 60 degrees.
80
80
  */
81
- var GAZE_PITCH_LIMIT = 0.3;
81
+ var GAZE_PITCH_LIMIT = (60 * Math.PI / 180) / GAZE_WEIGHT;
82
+
83
+ function gazePitchLimits(preferences) {
84
+ var prefs = preferences || {};
85
+ function limit(key) {
86
+ return Number.isFinite(prefs[key])
87
+ ? Math.max(0, Math.min(60, prefs[key])) * Math.PI / 180 / GAZE_WEIGHT
88
+ : GAZE_PITCH_LIMIT;
89
+ }
90
+ return { up: limit('gazePitchUp'), down: limit('gazePitchDown') };
91
+ }
82
92
 
83
93
  /**
84
94
  * How far the head may turn IN TOTAL while an authored clip is driving it, in radians.
@@ -794,7 +804,8 @@
794
804
  }
795
805
 
796
806
  var yaw = Math.max(-GAZE_YAW_LIMIT, Math.min(GAZE_YAW_LIMIT, aim.yaw)) * GAZE_WEIGHT * this.gazeBlend;
797
- var pitch = Math.max(-GAZE_PITCH_LIMIT, Math.min(GAZE_PITCH_LIMIT, aim.pitch)) * GAZE_WEIGHT * this.gazeBlend;
807
+ var pitchLimits = gazePitchLimits(this.scene && this.scene.preferences);
808
+ var pitch = Math.max(-pitchLimits.down, Math.min(pitchLimits.up, aim.pitch)) * GAZE_WEIGHT * this.gazeBlend;
798
809
  var axes = rest ? this.gazeAxes(head) : null;
799
810
 
800
811
  if (layeredOnClip) {
@@ -807,8 +818,7 @@
807
818
  var budget = Math.max(0, HEAD_LAYER_LIMIT - clipTurn);
808
819
  yaw = Math.max(-budget, Math.min(budget, yaw));
809
820
  // Pitch keeps its own (smaller) limit, but never more than what is left of the budget either.
810
- pitch = Math.max(-Math.min(GAZE_PITCH_LIMIT * GAZE_WEIGHT, budget),
811
- Math.min(Math.min(GAZE_PITCH_LIMIT * GAZE_WEIGHT, budget), pitch));
821
+ pitch = Math.max(-budget, Math.min(budget, pitch));
812
822
  if (axes) {
813
823
  this._quat.setFromAxisAngle(axes.yaw, yaw);
814
824
  this._quat2.setFromAxisAngle(axes.pitch, pitch);
@@ -1677,6 +1687,9 @@
1677
1687
  if (this.disposed) return;
1678
1688
  var w = Math.max(1, Math.round(width));
1679
1689
  var h = Math.max(1, Math.round(height));
1690
+ // Layout works in fractional CSS pixels. Reapplying the same rounded size clears
1691
+ // the framebuffer between rAFs when a settings reply arrives during an orbit.
1692
+ if (this.width === w && this.height === h) return;
1680
1693
  this.renderer.setSize(w, h, false);
1681
1694
  this.camera.aspect = w / h;
1682
1695
  this.camera.updateProjectionMatrix();
@@ -1763,6 +1776,8 @@
1763
1776
 
1764
1777
  /** Replace the current avatar with a hardware-ready one. */
1765
1778
  Scene.prototype.installAvatar = function (avatar, root) {
1779
+ (this.toonMaterials || []).forEach(function (entry) { if (entry.plain) entry.plain.dispose(); });
1780
+ this.toonMaterials = null;
1766
1781
  // Stop before the old avatar is replaced: stopping is what undoes the clip's
1767
1782
  // pose, and it has to happen while the old model is still installed. Every
1768
1783
  // clip is then dropped, because a clip is fitted to one model — the cached
@@ -1787,6 +1802,7 @@
1787
1802
  this.frameAvatar(root);
1788
1803
  this.stage.add(root);
1789
1804
  this.avatar = avatar;
1805
+ this.captureToonMaterials(root);
1790
1806
  this.kind = avatar.kind;
1791
1807
  /**
1792
1808
  * …and the collider switch, applied to the avatar that was just adopted.
@@ -1852,6 +1868,7 @@
1852
1868
  * model is loaded, so on a fresh pet that call finds nothing to snapshot and the first sweep would have
1853
1869
  * had no gravity to lean against (measured: `windState().joints === 0` on a real model with two chains).
1854
1870
  */
1871
+ this.applySprings(this.preferences);
1855
1872
  this.captureSpringBase();
1856
1873
  this.expressionBehaviour = { blink: 0, blinkExpression: 'blink', emotion: null, emotionWeight: 0 };
1857
1874
  this.expressionState = { nextBlinkAt: null, blinkUntil: 0, nextIdleEmotionAt: null, emotionUntil: 0 };
@@ -1860,6 +1877,73 @@
1860
1877
  this.motion.setGazeIdle(this.preferences.gazeIdleSeconds);
1861
1878
  this.updateShadowPlane();
1862
1879
  this.refitCamera();
1880
+ this.applyToonPreferences();
1881
+ };
1882
+
1883
+ /** Keep the loaded materials (and expression bindings) intact so the switch is reversible. */
1884
+ Scene.prototype.captureToonMaterials = function (root) {
1885
+ var self = this;
1886
+ this.toonMeshes = [];
1887
+ this.toonMaterials = [];
1888
+ var seen = new Map();
1889
+ root.traverse(function (node) {
1890
+ if (!node.isMesh || !node.material) return;
1891
+ var originals = Array.isArray(node.material) ? node.material : [node.material];
1892
+ var entries = originals.map(function (material) {
1893
+ if (!material.isMToonMaterial) return null;
1894
+ if (seen.has(material)) return seen.get(material);
1895
+ var entry = { material: material, toony: material.shadingToonyFactor, shift: material.shadingShiftFactor,
1896
+ shade: material.shadeColorFactor.clone(), width: material.outlineWidthFactor, plain: null };
1897
+ seen.set(material, entry);
1898
+ self.toonMaterials.push(entry);
1899
+ return entry;
1900
+ });
1901
+ if (entries.some(Boolean)) self.toonMeshes.push({ node: node, original: node.material, entries: entries, originals: originals });
1902
+ });
1903
+ };
1904
+
1905
+ Scene.prototype.applyToonPreferences = function () {
1906
+ var self = this;
1907
+ var id = this.currentModelId || this.preferences.modelId;
1908
+ var prefs = (this.preferences.toon || {})[id] || {};
1909
+ var disabled = prefs.enabled === false;
1910
+ (this.toonMaterials || []).forEach(function (entry) {
1911
+ var material = entry.material;
1912
+ material.shadingToonyFactor = prefs.shadingToonyFactor === undefined ? entry.toony : prefs.shadingToonyFactor;
1913
+ material.shadingShiftFactor = prefs.shadingShiftFactor === undefined ? entry.shift : prefs.shadingShiftFactor;
1914
+ material.shadeColorFactor.copy(entry.shade);
1915
+ if (prefs.shadeStrength !== undefined) material.shadeColorFactor.lerp(material.color, 1 - prefs.shadeStrength);
1916
+ material.outlineWidthFactor = entry.width * (prefs.outlineScale === undefined ? 1 : prefs.outlineScale);
1917
+ if (disabled && !entry.plain) {
1918
+ var plain = new self.THREE.MeshStandardMaterial();
1919
+ ['map', 'alphaMap', 'alphaTest', 'transparent', 'opacity', 'side', 'depthWrite', 'depthTest',
1920
+ 'normalMap', 'normalScale', 'emissive', 'emissiveMap', 'emissiveIntensity', 'color'].forEach(function (key) {
1921
+ if (material[key] === undefined) return;
1922
+ if (plain[key] && typeof plain[key].copy === 'function') plain[key].copy(material[key]);
1923
+ else plain[key] = material[key];
1924
+ });
1925
+ plain.visible = !material.isOutline;
1926
+ entry.plain = plain;
1927
+ }
1928
+ });
1929
+ (this.toonMeshes || []).forEach(function (mesh) {
1930
+ mesh.node.material = disabled
1931
+ ? (Array.isArray(mesh.original) ? mesh.originals.map(function (m, i) { return mesh.entries[i] ? mesh.entries[i].plain : m; })
1932
+ : mesh.entries[0].plain)
1933
+ : mesh.original;
1934
+ });
1935
+ this.toonDisabled = disabled;
1936
+ };
1937
+
1938
+ Scene.prototype.syncPlainMaterials = function () {
1939
+ if (!this.toonDisabled) return;
1940
+ (this.toonMaterials || []).forEach(function (entry) {
1941
+ if (!entry.plain) return;
1942
+ // Expressions still target the authored MToon material, including UV animation.
1943
+ entry.plain.color.copy(entry.material.color);
1944
+ entry.plain.emissive.copy(entry.material.emissive);
1945
+ entry.plain.opacity = entry.material.opacity;
1946
+ });
1863
1947
  };
1864
1948
 
1865
1949
  /** A soft ground blob, so the pet does not appear to float. */
@@ -3388,7 +3472,8 @@
3388
3472
  var x = Math.max(-1, Math.min(1, aim.x));
3389
3473
  var y = Math.max(-1, Math.min(1, aim.y));
3390
3474
  // Screen y grows downward, pitch grows upward.
3391
- return { yaw: x * GAZE_YAW_LIMIT, pitch: -y * GAZE_PITCH_LIMIT };
3475
+ var limits = gazePitchLimits(this.preferences);
3476
+ return { yaw: x * GAZE_YAW_LIMIT, pitch: -y * (y < 0 ? limits.up : limits.down) };
3392
3477
  };
3393
3478
 
3394
3479
  /** Advance one frame. `dt` is in seconds. */
@@ -3445,6 +3530,7 @@
3445
3530
  this.advanceExpressions(dt);
3446
3531
  this.applyExpressions(this.preferences);
3447
3532
 
3533
+ this.syncPlainMaterials();
3448
3534
  this.renderer.render(this.scene, this.camera);
3449
3535
  this.frame += 1;
3450
3536
  };
@@ -3456,6 +3542,7 @@
3456
3542
  Scene.prototype.applyPreferences = function (preferences) {
3457
3543
  var raw = preferences && typeof preferences === 'object' ? preferences : {};
3458
3544
  this.preferences = this.core.cleanPreferences(preferences);
3545
+ this.applyToonPreferences();
3459
3546
  // A preferences push only carries `motions` when the caller meant to change
3460
3547
  // it. cleanPreferences always produces a (possibly empty) key, so adopting it
3461
3548
  // unconditionally would silently wipe a map set through setMotionMap.
@@ -3649,6 +3736,40 @@
3649
3736
  };
3650
3737
  };
3651
3738
 
3739
+ /** The rendered eyes' midpoint, in canvas CSS pixels, independent of framing and camera orbit. */
3740
+ Scene.prototype.gazeOrigin = function () {
3741
+ var avatar = this.avatar;
3742
+ if (!avatar || !this.THREE || !this.camera) return null;
3743
+ var point = this._gazeOriginVec || (this._gazeOriginVec = new this.THREE.Vector3());
3744
+ var other = this._gazeOtherEyeVec || (this._gazeOtherEyeVec = new this.THREE.Vector3());
3745
+ var humanoid = avatar.vrm && avatar.vrm.humanoid;
3746
+ var left = humanoid && typeof humanoid.getRawBoneNode === 'function' ? humanoid.getRawBoneNode('leftEye') : null;
3747
+ var right = humanoid && typeof humanoid.getRawBoneNode === 'function' ? humanoid.getRawBoneNode('rightEye') : null;
3748
+ left = left || (avatar.bones && avatar.bones.leftEye);
3749
+ right = right || (avatar.bones && avatar.bones.rightEye);
3750
+ var source;
3751
+ if (left && right) {
3752
+ left.getWorldPosition(point); right.getWorldPosition(other);
3753
+ point.add(other).multiplyScalar(0.5);
3754
+ source = 'eyes';
3755
+ } else if (avatar.vrm && avatar.vrm.lookAt && typeof avatar.vrm.lookAt.getLookAtWorldPosition === 'function') {
3756
+ // The loader has already converted VRM 0/1 coordinate conventions for this offset.
3757
+ if (avatar.root) avatar.root.updateWorldMatrix(true, true);
3758
+ avatar.vrm.lookAt.getLookAtWorldPosition(point);
3759
+ source = 'lookAt';
3760
+ } else {
3761
+ var head = gazeNodeFor(avatar);
3762
+ if (!head) return null;
3763
+ head.getWorldPosition(point);
3764
+ source = 'head';
3765
+ }
3766
+ this.camera.updateMatrixWorld(true);
3767
+ var projected = this.projectToCanvas(point);
3768
+ if (!projected || projected.z < -1 || projected.z > 1) return null;
3769
+ projected.source = source;
3770
+ return projected;
3771
+ };
3772
+
3652
3773
  /**
3653
3774
  * Apply the draught to the chains, decay it, and keep it LOCAL.
3654
3775
  *
@@ -3873,6 +3994,10 @@
3873
3994
  */
3874
3995
  Scene.prototype.setSpringDefaults = function (defaults) {
3875
3996
  this.springDefaults = defaults && typeof defaults === 'object' ? defaults : null;
3997
+ // Catalog defaults can arrive after the avatar and preferences on either surface.
3998
+ // Apply them now; waiting for another preference edit makes a reload appear to fix physics.
3999
+ this.applySprings(this.preferences);
4000
+ this.captureSpringBase();
3876
4001
  };
3877
4002
 
3878
4003
  /**
@@ -4939,6 +5064,8 @@
4939
5064
 
4940
5065
  Scene.prototype.dispose = function () {
4941
5066
  if (this.disposed) return;
5067
+ (this.toonMeshes || []).forEach(function (mesh) { mesh.node.material = mesh.original; });
5068
+ (this.toonMaterials || []).forEach(function (entry) { if (entry.plain) entry.plain.dispose(); });
4942
5069
  this.disposed = true;
4943
5070
  // Stop and uncache every mixer before the renderer goes: a mixer holds a
4944
5071
  // binding table that references the model's bones, so leaving them behind
@@ -3,11 +3,20 @@
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
+ <!--
7
+ The title, the nav, the table headers and every row below carry `data-i18n*`, and the Chinese text stays INLINE as
8
+ the no-JS fallback: this page's script can fail to load (that is what the watchdog at the bottom reports), and a
9
+ page that failed should still read in one language rather than show nothing. `lib/config-page.js` owns the table
10
+ and `core.applyI18n` writes these places; a key the table lacks leaves the markup alone.
11
+
12
+ Both settings pages are written in the two languages by that ONE script and its ONE table, so a key here is shared
13
+ with 通用设置 — `sectionModel` names both this page's heading and its nav entry.
14
+ -->
15
+ <title data-i18n="titleModel">vrmpet3d · 模型参数</title>
7
16
  <meta name="color-scheme" content="dark light">
8
17
  <!--
9
- The stylesheet both settings pages share. One file rather than a copy in each page: the two pages
10
- render the same rows, tables and status lines, and a copy is how the two drift apart.
18
+ The stylesheet both settings pages share. One file rather than a copy in each page: the two pages render
19
+ the same rows, tables and status lines, and a copy is how the two drift apart.
11
20
 
12
21
  The MODEL page (`/vrmpet3d/config`). Its sections are the ones that are about ONE model, in this order:
13
22
  模型 → 表情 → 部位 → 弹簧骨. Everything else (导入, 状态, 碎碎念, 视线, 鼠标互动, 其他) is a section of
@@ -27,44 +36,55 @@
27
36
  -->
28
37
  <div class="page-loading" role="status" aria-live="polite">
29
38
  <span class="page-loading-spinner" aria-hidden="true"></span>
30
- <span>正在加载设置…</span>
39
+ <span data-i18n="loading">正在加载设置…</span>
31
40
  </div>
32
41
 
33
- <header class="page-header"><div class="eyebrow">VRMPET3D · SETTINGS</div><h1>模型参数</h1><div id="status" role="status" aria-live="polite">正在读取…</div></header>
34
- <nav class="section-nav" aria-label="设置分组"><a href="#settings-0">模型</a><a href="#settings-1">表情</a><a href="#settings-2">部位</a><a href="#settings-3">弹簧骨</a></nav>
42
+ <header class="page-header"><div class="eyebrow">VRMPET3D · SETTINGS</div><h1 data-i18n="headingModel">模型参数</h1><div id="status" role="status" aria-live="polite" data-i18n="reading">正在读取…</div></header>
43
+ <nav class="section-nav" aria-label="设置分组" data-i18n-aria="navSections"><a href="#settings-0" data-i18n="sectionModel">模型</a><a href="#settings-1" data-i18n="sectionExpression">表情</a><a href="#settings-2" data-i18n="sectionParts">部位</a><a href="#settings-3" data-i18n="sectionSprings">弹簧骨</a></nav>
35
44
  <main>
36
45
  <section class="settings-section" id="settings-0">
37
- <h2>模型</h2>
46
+ <h2 data-i18n="sectionModel">模型</h2>
38
47
  <div class="card">
39
48
  <div class="row">
40
- <label class="k" for="model-select">当前模型</label>
49
+ <label class="k" for="model-select" data-i18n="currentModel">当前模型</label>
41
50
  <select id="model-select" class="grow"></select>
42
51
  <span class="pill" id="model-spec">—</span>
43
52
  </div>
44
- <div class="row"><span class="k">可用信息</span><span class="grow note" id="model-meta">—</span></div>
53
+ <div class="row"><span class="k" data-i18n="availableInfo">可用信息</span><span class="grow note" id="model-meta">—</span></div>
45
54
  </div>
46
55
 
56
+ <div class="card" id="toon-card" style="margin-top:12px">
57
+ <h3 data-i18n="toonTitle">卡通增强</h3>
58
+ <div class="row"><span class="k" data-i18n="toonState">材质状态</span><span class="grow note" id="toon-state" role="status">—</span></div>
59
+ <div class="row">
60
+ <label class="k" for="toon-mode">MToon</label>
61
+ <select id="toon-mode"><option value="original" data-i18n="toonOriginal">模型原始</option><option value="on" data-i18n="on">开启</option><option value="off" data-i18n="toonDisabled">关闭</option></select>
62
+ <button id="toon-reset" type="button" data-i18n="toonReset">恢复模型原始参数</button>
63
+ </div>
64
+ <div id="toon-controls"></div>
65
+ <p class="note" data-i18n="toonNote">参数仅保存到当前模型,留空使用模型原始值。关闭 MToon 后使用普通光照材质。描边倍率只调整模型已有的描边。</p>
66
+ </div>
47
67
  </section>
48
68
 
49
69
  <section class="settings-section" id="settings-1">
50
- <h2>表情 <span id="expression-count"></span></h2>
70
+ <h2><span data-i18n="sectionExpression">表情</span> <span id="expression-count"></span></h2>
51
71
  <div class="card">
52
72
  <div class="row">
53
- <label class="k" for="blink-enabled">眨眼</label>
54
- <label class="inline"><input type="checkbox" id="blink-enabled"> 开启</label>
55
- <span class="k sub">间隔</span>
73
+ <label class="k" for="blink-enabled" data-i18n="blink">眨眼</label>
74
+ <label class="inline"><input type="checkbox" id="blink-enabled"> <span data-i18n="on">开启</span></label>
75
+ <span class="k sub" data-i18n="interval">间隔</span>
56
76
  <input type="number" id="blink-min" step="0.5" min="0.5" max="60"> –
57
- <input type="number" id="blink-max" step="0.5" min="0.5" max="60"> 秒
77
+ <input type="number" id="blink-max" step="0.5" min="0.5" max="60"> <span data-i18n="secondsUnit">秒</span>
58
78
  </div>
59
79
  <div class="row">
60
- <label class="k" for="emotion-enabled">情绪</label>
61
- <label class="inline"><input type="checkbox" id="emotion-enabled"> 开启</label>
62
- <span class="k sub">空闲间隔</span>
80
+ <label class="k" for="emotion-enabled" data-i18n="emotion">情绪</label>
81
+ <label class="inline"><input type="checkbox" id="emotion-enabled"> <span data-i18n="on">开启</span></label>
82
+ <span class="k sub" data-i18n="idleInterval">空闲间隔</span>
63
83
  <input type="number" id="emotion-idle-min" step="1" min="2" max="600"> –
64
- <input type="number" id="emotion-idle-max" step="1" min="2" max="600"> 秒
84
+ <input type="number" id="emotion-idle-max" step="1" min="2" max="600"> <span data-i18n="secondsUnit">秒</span>
65
85
  </div>
66
86
  <table id="emotion-table">
67
- <thead><tr><th style="width:76px">情境</th><th style="width:58px">使用</th><th>表情</th><th style="width:104px">持续时间</th></tr></thead>
87
+ <thead><tr><th style="width:76px" data-i18n="colSituation">情境</th><th style="width:58px" data-i18n="colUse">使用</th><th data-i18n="colExpression">表情</th><th style="width:104px" data-i18n="colHold">持续时间</th></tr></thead>
68
88
  <tbody id="emotion-body"></tbody>
69
89
  </table>
70
90
  </div>
@@ -72,7 +92,7 @@
72
92
  </section>
73
93
 
74
94
  <section class="settings-section" id="settings-2">
75
- <h2>部位 <span id="part-count"></span></h2>
95
+ <h2><span data-i18n="sectionParts">部位</span> <span id="part-count"></span></h2>
76
96
  <!--
77
97
  默认折叠(operator, 2026-10-14: 「"部位"区域默认折叠,内容太多了」).
78
98
 
@@ -83,22 +103,22 @@
83
103
  the markup. The count beside the heading is filled whether the fold is open or shut.
84
104
  -->
85
105
  <details id="parts-fold">
86
- <summary>展开部位列表</summary>
106
+ <summary data-i18n="partsExpand">展开部位列表</summary>
87
107
  <div class="card">
88
108
  <table id="part-table">
89
109
  <thead><tr>
90
- <th style="width:150px">部位</th>
91
- <th style="width:58px">使用</th>
92
- <th>表情</th>
93
- <th style="width:104px">持续时间</th>
94
- <th style="width:290px">结构</th>
110
+ <th style="width:150px" data-i18n="colPart">部位</th>
111
+ <th style="width:58px" data-i18n="colUse">使用</th>
112
+ <th data-i18n="colExpression">表情</th>
113
+ <th style="width:104px" data-i18n="colHold">持续时间</th>
114
+ <th style="width:290px" data-i18n="colStructure">结构</th>
95
115
  <th style="width:58px"></th>
96
116
  </tr></thead>
97
117
  <tbody id="part-body"></tbody>
98
118
  </table>
99
119
  <div class="row" style="margin-top:10px">
100
- <button id="part-add" type="button">新增部位</button>
101
- <span class="note">最多 <span id="part-max"></span> 个</span>
120
+ <button id="part-add" type="button" data-i18n="partsAdd">新增部位</button>
121
+ <span class="note"><span data-i18n="partsMaxPrefix">最多</span> <span id="part-max"></span><span data-i18n="partsMaxSuffix"> 个</span></span>
102
122
  </div>
103
123
  <!--
104
124
  The structure BROWSER. It replaced a native `<input list>` + `<datalist>`: in the settings window that popup
@@ -107,9 +127,9 @@
107
127
  -->
108
128
  <div class="part-picker" id="part-picker" hidden>
109
129
  <div class="part-picker-head">
110
- <input type="text" id="part-picker-filter" placeholder="筛选结构名">
130
+ <input type="text" id="part-picker-filter" placeholder="筛选结构名" data-i18n-placeholder="filterStructures">
111
131
  <span class="note" id="part-picker-count"></span>
112
- <button type="button" id="part-picker-close">关闭</button>
132
+ <button type="button" id="part-picker-close" data-i18n="close">关闭</button>
113
133
  </div>
114
134
  <div class="part-picker-list" id="part-picker-list" tabindex="0"></div>
115
135
  </div>
@@ -119,12 +139,12 @@
119
139
  </section>
120
140
 
121
141
  <section class="settings-section" id="settings-3">
122
- <h2>弹簧骨 <span id="spring-count"></span></h2>
142
+ <h2><span data-i18n="sectionSprings">弹簧骨</span> <span id="spring-count"></span></h2>
123
143
  <div class="card">
124
144
  <div id="spring-list"></div>
125
145
  <div class="row" style="margin-top:10px">
126
- <label class="k" for="spring-motion-physics">动作播放时</label>
127
- <label class="inline"><input type="checkbox" id="spring-motion-physics"> 头发与裙摆继续摆动</label>
146
+ <label class="k" for="spring-motion-physics" data-i18n="springMotionWhile">动作播放时</label>
147
+ <label class="inline"><input type="checkbox" id="spring-motion-physics"> <span data-i18n="springMotionBox">头发与裙摆继续摆动</span></label>
128
148
  </div>
129
149
  <!--
130
150
  弹簧骨碰撞:全部关闭(默认)/ 全部开启 / 自定义(operator, 2026-10-14).
@@ -136,11 +156,11 @@
136
156
  because comparing the two states in one sitting is the only way to answer 穿模 by looking.
137
157
  -->
138
158
  <div class="row" style="margin-top:10px">
139
- <label class="k" for="spring-colliders">弹簧骨碰撞</label>
159
+ <label class="k" for="spring-colliders" data-i18n="springColliders">弹簧骨碰撞</label>
140
160
  <select id="spring-colliders">
141
- <option value="off">全部关闭(默认)</option>
142
- <option value="all">全部开启</option>
143
- <option value="custom">自定义</option>
161
+ <option value="off" data-i18n="collidersOff">全部关闭(默认)</option>
162
+ <option value="all" data-i18n="collidersAll">全部开启</option>
163
+ <option value="custom" data-i18n="collidersCustom">自定义</option>
144
164
  </select>
145
165
  </div>
146
166
  <!--
@@ -152,11 +172,11 @@
152
172
  no colliders shows no list rather than switches that do nothing.
153
173
  -->
154
174
  <details id="spring-colliders-detail" hidden style="margin-top:8px">
155
- <summary>逐个碰撞体 <span id="spring-collider-count"></span></summary>
175
+ <summary><span data-i18n="collidersDetail">逐个碰撞体</span> <span id="spring-collider-count"></span></summary>
156
176
  <div id="spring-collider-list" style="margin-top:8px;display:flex;flex-direction:column;gap:6px"></div>
157
177
  </details>
158
178
  <div class="row" style="margin-top:10px">
159
- <button id="spring-reset-all">全部恢复默认</button>
179
+ <button id="spring-reset-all" data-i18n="springResetAll">全部恢复默认</button>
160
180
  </div>
161
181
  </div>
162
182
 
@@ -168,23 +188,40 @@
168
188
  A settings page that can only say "正在读取…" is useless when something fails: the text is the HTML's
169
189
  own placeholder, and a script that never ran, 404'd or threw at its first line looks exactly like a
170
190
  slow host. So this runs FIRST, catches what it can, and after two seconds says what is actually wrong.
191
+
192
+ It runs BEFORE `lib/core.js`, which is the whole point — the case it exists for is a core that never
193
+ loaded — so it cannot use `core.textFor` and carries its own tiny map instead: three sentences plus the
194
+ brackets a filename goes in, picked from `navigator.language` in one line. Chinese is the fallback,
195
+ because the markup around it is Chinese. Keep it small: this is a watchdog, not a second i18n system.
171
196
  -->
172
197
  <script>
173
198
  (function () {
199
+ var ZH = /^(zh|cn|chinese)/i.test(navigator.language || '');
200
+ var WORDS = ZH
201
+ ? {
202
+ error: "页面脚本出错:",
203
+ file: "(来自 {name})",
204
+ coreMissing: "初始化卡住:core.js 没有加载(检查 /vrmpet3d/window/lib/core.js 是否 404)。",
205
+ stuck: "初始化卡住:脚本已加载但没有完成初始化(看看窗口的控制台,或宿主是否提供 /vrmpet3d/settings)。",
206
+ }
207
+ : {
208
+ error: "Page script error: ",
209
+ file: "(from {name})",
210
+ coreMissing: "Stuck at startup: core.js did not load (check whether /vrmpet3d/window/lib/core.js is a 404).",
211
+ stuck: "Stuck at startup: the script loaded but never finished initialising (check the window console, or whether the host serves /vrmpet3d/settings).",
212
+ };
174
213
  function show(message) {
175
214
  var status = document.getElementById("status");
176
215
  if (status) { status.textContent = message; status.className = "warn"; }
177
216
  }
178
217
  window.addEventListener("error", function (event) {
179
- show("页面脚本出错:" + (event.message || event.type) + (event.filename ? " (" + event.filename + ")" : ""));
218
+ show(WORDS.error + (event.message || event.type)
219
+ + (event.filename ? " " + WORDS.file.replace("{name}", event.filename) : ""));
180
220
  }, true);
181
221
  window.setTimeout(function () {
182
222
  if (window.__VRMPET3D_CONFIG_STATE__) return;
183
- if (!window.__VRMPET3D_CORE__) {
184
- show("初始化卡住:core.js 没有加载(检查 /vrmpet3d/window/lib/core.js 是否 404)。");
185
- return;
186
- }
187
- show("初始化卡住:脚本已加载但没有完成初始化(看看窗口的控制台,或宿主是否提供 /vrmpet3d/settings)。");
223
+ if (!window.__VRMPET3D_CORE__) { show(WORDS.coreMissing); return; }
224
+ show(WORDS.stuck);
188
225
  }, 2000);
189
226
  })();
190
227
  </script>