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/README.md +2 -0
- package/desktop/main.js +47 -4
- package/desktop/preload.js +7 -2
- package/lib/client.core.js +487 -53
- package/lib/client.js +391 -57
- package/lib/client.menu.js +14 -4
- package/lib/client.scene.js +134 -7
- package/lib/config-page.html +82 -45
- package/lib/config-page.js +1271 -252
- package/lib/core.js +487 -53
- package/lib/desktop-page.js +298 -44
- package/lib/desktop.js +18 -1
- package/lib/global-page.html +172 -101
- package/lib/index.js +196 -58
- package/lib/menu.js +14 -4
- package/lib/model-picker.js +139 -49
- package/lib/picker-page.html +34 -8
- package/lib/picker-page.js +192 -11
- package/lib/scene.js +134 -7
- package/lib/setup-page.html +68 -49
- package/lib/setup-page.js +395 -42
- package/lib/vrm-info.js +16 -1
- package/lib/whale.js +191 -0
- package/package.json +1 -1
package/lib/client.menu.js
CHANGED
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
reloadModel: 'Reload model',
|
|
114
114
|
dance: 'Dance for me',
|
|
115
115
|
modelSettings: "This model's parameters",
|
|
116
|
-
surfaceWindow: '
|
|
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
|
*
|
package/lib/client.scene.js
CHANGED
|
@@ -75,10 +75,20 @@
|
|
|
75
75
|
var GAZE_YAW_LIMIT = ((80 * Math.PI) / 180) / GAZE_WEIGHT;
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
package/lib/config-page.html
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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">
|
|
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">
|
|
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
|
|
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"
|
|
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
|
|
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">
|
|
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
|
|
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(
|
|
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
|
-
|
|
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>
|