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/config-page.js
CHANGED
|
@@ -31,9 +31,18 @@
|
|
|
31
31
|
if (!core) {
|
|
32
32
|
// Not a throw: a thrown error here leaves the page saying '正在读取…' for ever, which is the least
|
|
33
33
|
// useful thing a settings page can do. Say it on screen instead.
|
|
34
|
+
//
|
|
35
|
+
// The sentence is picked here rather than read from `TEXT`, because there is no `TEXT` to read — this is the
|
|
36
|
+
// branch for a core that never loaded, so `core.textFor` and `core.resolveLanguage` are both gone. The page's
|
|
37
|
+
// inline watchdog (which runs BEFORE core.js by design) says the same thing in the browser's language two
|
|
38
|
+
// seconds later; this is the immediate version.
|
|
34
39
|
var stuck = document.getElementById('status');
|
|
35
40
|
if (stuck) {
|
|
36
|
-
|
|
41
|
+
var nav = globalThis.navigator;
|
|
42
|
+
var chinese = /^(zh|cn|chinese)/i.test(nav ? (nav.language || '') : '');
|
|
43
|
+
stuck.textContent = chinese
|
|
44
|
+
? '初始化失败:lib/core.js 没有加载(/vrmpet3d/window/lib/core.js)。'
|
|
45
|
+
: 'Initialisation failed: lib/core.js did not load (/vrmpet3d/window/lib/core.js).';
|
|
37
46
|
stuck.className = 'warn';
|
|
38
47
|
}
|
|
39
48
|
return;
|
|
@@ -57,6 +66,781 @@
|
|
|
57
66
|
return declared === 'global' ? 'global' : 'model';
|
|
58
67
|
}
|
|
59
68
|
|
|
69
|
+
/**
|
|
70
|
+
* THE WORDS, in the two languages this plugin speaks.
|
|
71
|
+
*
|
|
72
|
+
* ONE table for BOTH pages, because one script drives them: the keys are shared and each page uses the ones its
|
|
73
|
+
* markup mentions. That is why `titleModel`/`headingModel` and their 通用 counterparts sit side by side instead of
|
|
74
|
+
* being chosen here — a page's own name belongs to the page.
|
|
75
|
+
*
|
|
76
|
+
* The SHAPE is this repository's convention (`lib/menu.js`'s `LABELS`, `lib/model-picker.js`'s `TEXT`, and the
|
|
77
|
+
* accessors in `core`): `{ zh: {...}, en: {...} }`, read through `core.textFor`/`core.textOf`. A surface owns the
|
|
78
|
+
* words it draws; one table for every surface would make six of them edit the same file.
|
|
79
|
+
*
|
|
80
|
+
* EVERY entry is read at CALL time (`t(...)`), never captured into a module variable while the script boots: the
|
|
81
|
+
* language can change while the page is open (通用设置's 界面语言), and a label captured at boot would keep the old
|
|
82
|
+
* words for the rest of the session. A sentence with a value inside it carries a `{token}` and goes through
|
|
83
|
+
* `fill` — concatenating the pieces around the value here instead is how an English sentence ends up with Chinese
|
|
84
|
+
* word order.
|
|
85
|
+
*
|
|
86
|
+
* NOT here, on purpose: anything that comes from the model, the catalogue or the store — expression, clip,
|
|
87
|
+
* structure, provider and model names, ids, file paths, `assets/motions/…`, numbers and units — and the 碎碎念
|
|
88
|
+
* content itself, which `lib/core.js` owns and already ships per language.
|
|
89
|
+
*/
|
|
90
|
+
var TEXT = {
|
|
91
|
+
zh: {
|
|
92
|
+
// ---- the chrome, and the name each page gives itself ----
|
|
93
|
+
titleModel: 'vrmpet3d · 模型参数',
|
|
94
|
+
gazePitchUp: '最大抬头角度', gazePitchDown: '最大低头角度', gazePitchTest: '测试 20° / 30°',
|
|
95
|
+
gazeCurveMode: '曲线模式', gazeAxes: '分轴曲线', gazeRadial: '二维距离曲线',
|
|
96
|
+
gazeSpatial: '空间角度', gazeCurveSpatial: '空间角度曲率', gazeDistance: '注视距离',
|
|
97
|
+
gazeSpatialHint: '距离以满偏距离为单位,越大转头越缓。同高时,鼠标越偏向左右,俯仰越小;与双眼同高时俯仰为零。默认曲率 1、注视距离 0.1。',
|
|
98
|
+
gazeSpanHint: '满偏距离填 0 为自动:水平和垂直均使用屏幕宽度的一半。',
|
|
99
|
+
gazeCurveX: '水平曲率', gazeCurveY: '垂直曲率', gazeCurveRadial: '二维距离曲率',
|
|
100
|
+
gazeCurveHint: '数值越小,中心附近越灵敏;1 为线性。二维距离模式按满偏距离归一化后计算方向与强度,可减少远处光标上下穿过视线水平线时的俯仰跳变。各模式的参数分别保存。',
|
|
101
|
+
gazePitchHint: '自动为抬头、低头各 60°。范围 0–60°;0 表示不向该方向转头。这是视线跟踪的附加角度,动作本身的头部姿势仍会保留。',
|
|
102
|
+
toonTitle: '卡通增强', toonState: '材质状态', toonOriginal: '模型原始', toonReset: '恢复模型原始参数',
|
|
103
|
+
toonNote: '参数仅保存到当前模型,留空使用模型原始值。关闭 MToon 后使用普通光照材质。描边倍率只调整模型已有的描边。',
|
|
104
|
+
toonLoading: '正在读取当前模型材质…', toonUnsupported: '当前模型未声明 MToon 材质,暂不支持转换。',
|
|
105
|
+
toonStatus: '{n}/{total} 个原生 MToon 材质 · 当前{state}', toonEnabled: '开启', toonDisabled: '关闭',
|
|
106
|
+
toonToony: '明暗分界硬度', toonShift: '明暗分界偏移', toonShade: '阴影强度', toonOutline: '描边倍率',
|
|
107
|
+
toonMixed: '模型原始(各材质不同)', toonNoOutline: '模型没有启用描边',
|
|
108
|
+
titleGlobal: 'vrmpet3d · 通用设置',
|
|
109
|
+
headingModel: '模型参数',
|
|
110
|
+
headingGlobal: '通用设置',
|
|
111
|
+
loading: '正在加载设置…',
|
|
112
|
+
reading: '正在读取…',
|
|
113
|
+
navSections: '设置分组',
|
|
114
|
+
// ---- the section names, which are also the nav entries ----
|
|
115
|
+
sectionModel: '模型',
|
|
116
|
+
sectionExpression: '表情',
|
|
117
|
+
sectionParts: '部位',
|
|
118
|
+
sectionSprings: '弹簧骨',
|
|
119
|
+
sectionDisplay: '显示与外观',
|
|
120
|
+
sectionGaze: '视线',
|
|
121
|
+
sectionMouse: '鼠标互动与物理',
|
|
122
|
+
sectionState: '状态与动作',
|
|
123
|
+
sectionSpeech: '碎碎念',
|
|
124
|
+
sectionModelImport: '模型导入',
|
|
125
|
+
sectionMotionImport: '动作导入',
|
|
126
|
+
sectionOther: '其他',
|
|
127
|
+
// ---- words a row repeats ----
|
|
128
|
+
on: '开启',
|
|
129
|
+
reset: '复位',
|
|
130
|
+
close: '关闭',
|
|
131
|
+
auto: '自动',
|
|
132
|
+
secondsUnit: '秒',
|
|
133
|
+
remove: '移除',
|
|
134
|
+
// ---- 界面语言 ----
|
|
135
|
+
language: '界面语言',
|
|
136
|
+
languageAuto: '自动',
|
|
137
|
+
languageZh: '中文',
|
|
138
|
+
languageEn: 'English',
|
|
139
|
+
// ---- 模型参数: 模型 / 表情 ----
|
|
140
|
+
currentModel: '当前模型',
|
|
141
|
+
availableInfo: '可用信息',
|
|
142
|
+
blink: '眨眼',
|
|
143
|
+
interval: '间隔',
|
|
144
|
+
emotion: '情绪',
|
|
145
|
+
idleInterval: '空闲间隔',
|
|
146
|
+
colSituation: '情境',
|
|
147
|
+
colUse: '使用',
|
|
148
|
+
colExpression: '表情',
|
|
149
|
+
colHold: '持续时间',
|
|
150
|
+
// ---- 模型参数: 部位 ----
|
|
151
|
+
partsExpand: '展开部位列表',
|
|
152
|
+
colPart: '部位',
|
|
153
|
+
colStructure: '结构',
|
|
154
|
+
partsAdd: '新增部位',
|
|
155
|
+
partsMaxPrefix: '最多',
|
|
156
|
+
partsMaxSuffix: ' 个',
|
|
157
|
+
filterStructures: '筛选结构名',
|
|
158
|
+
// ---- 模型参数: 弹簧骨 ----
|
|
159
|
+
springMotionWhile: '动作播放时',
|
|
160
|
+
springMotionBox: '头发与裙摆继续摆动',
|
|
161
|
+
springColliders: '弹簧骨碰撞',
|
|
162
|
+
collidersOff: '全部关闭(默认)',
|
|
163
|
+
collidersAll: '全部开启',
|
|
164
|
+
collidersCustom: '自定义',
|
|
165
|
+
collidersDetail: '逐个碰撞体',
|
|
166
|
+
springResetAll: '全部恢复默认',
|
|
167
|
+
// ---- 通用设置: 显示与外观 ----
|
|
168
|
+
petSurface: '桌宠界面',
|
|
169
|
+
surfaceApp: '内置宠物',
|
|
170
|
+
sizeApp: '应用内大小',
|
|
171
|
+
sizeWindow: '悬浮窗大小',
|
|
172
|
+
idleNap: '空闲休眠',
|
|
173
|
+
sleep30: '30 秒',
|
|
174
|
+
sleep120: '2 分钟',
|
|
175
|
+
sleep300: '5 分钟',
|
|
176
|
+
debugMode: '调试模式',
|
|
177
|
+
debugModeBox: '独立桌宠与内置桌宠同时显示',
|
|
178
|
+
view: '视角',
|
|
179
|
+
viewReset: '复位视角',
|
|
180
|
+
pet: '宠物',
|
|
181
|
+
hide: '隐藏',
|
|
182
|
+
// ---- 通用设置: 视线 ----
|
|
183
|
+
lookAtPointer: '看向光标',
|
|
184
|
+
curve: '曲率',
|
|
185
|
+
span: '满偏距离',
|
|
186
|
+
pxHorizontal: 'px 横向',
|
|
187
|
+
pxVertical: 'px 纵向',
|
|
188
|
+
gazeIdle: '静止停看',
|
|
189
|
+
readingWindow: '(正在读取悬浮窗…)',
|
|
190
|
+
// ---- 通用设置: 鼠标互动与物理 ----
|
|
191
|
+
springPhysics: '弹簧骨物理',
|
|
192
|
+
windPower: '划过强度',
|
|
193
|
+
windRadius: '作用范围',
|
|
194
|
+
windDecay: '余韵',
|
|
195
|
+
windSpeed: '满力速度',
|
|
196
|
+
pxPerSecond: 'px/秒',
|
|
197
|
+
// ---- 通用设置: 状态与动作 ----
|
|
198
|
+
playMotion: '播放动画',
|
|
199
|
+
// ---- 通用设置: 碎碎念 (the markup) ----
|
|
200
|
+
speechDisclaimer: '<strong>碎碎念只是玩笑,请勿当真。</strong>它不会对你的项目或文件做任何实际操作,也不会干扰 DSH 的运行;低配机器上 3D 渲染与弹簧骨物理可能轻微影响性能,关掉桌宠或调小尺寸即可缓解。',
|
|
201
|
+
speechEnable: '开启',
|
|
202
|
+
speechTalk: '说话',
|
|
203
|
+
speechHold: '每句显示',
|
|
204
|
+
speechGap: '最短间隔',
|
|
205
|
+
speechIdleInterval: '空闲嘀咕间隔',
|
|
206
|
+
speechWorkInterval: '工作嘀咕间隔',
|
|
207
|
+
speechSource: '台词来源',
|
|
208
|
+
sourceBuiltin: '内置短语',
|
|
209
|
+
sourceCustom: '自定义短语',
|
|
210
|
+
sourceAi: 'AI 生成',
|
|
211
|
+
speechAiFrom: 'AI 来源',
|
|
212
|
+
providerDsh: 'DSH 的 Provider',
|
|
213
|
+
providerCustom: '自定义 Provider',
|
|
214
|
+
speechModel: '模型',
|
|
215
|
+
speechKeyClear: '清除',
|
|
216
|
+
persona: '人格',
|
|
217
|
+
temperature: '温度',
|
|
218
|
+
timeout: '超时',
|
|
219
|
+
speechTest: '试一句',
|
|
220
|
+
generate: '生成',
|
|
221
|
+
// ---- 通用设置: 导入 ----
|
|
222
|
+
importModel: '导入模型',
|
|
223
|
+
pickModelFile: '选择模型文件…',
|
|
224
|
+
manualPath: '手动输入路径',
|
|
225
|
+
filePath: '文件路径',
|
|
226
|
+
import: '导入',
|
|
227
|
+
importFolder: '导入目录',
|
|
228
|
+
pickMotionDir: '选择文件夹…',
|
|
229
|
+
path: '路径',
|
|
230
|
+
add: '添加',
|
|
231
|
+
// ---- 通用设置: 其他 ----
|
|
232
|
+
setupSection: '初次安装引导',
|
|
233
|
+
setupRerun: '重新运行引导',
|
|
234
|
+
// ---- the script's own words ----
|
|
235
|
+
writeFailed: '写入设置失败:',
|
|
236
|
+
readTimeout: '读取设置超时:宿主没有在 8 秒内回应(其余功能仍可手动重试)。',
|
|
237
|
+
catalogFailed: '未能读取模型列表:宿主没有提供 /vrmpet3d/catalog',
|
|
238
|
+
importTag: '已导入',
|
|
239
|
+
legacyDir: '旧版目录',
|
|
240
|
+
importedModelsNote: '已导入 {models} 个模型文件{legacy},当前模型列表里有 {total} 个模型。',
|
|
241
|
+
legacyDirsClause: '、{dirs} 个旧版目录',
|
|
242
|
+
onlyBundledModels: '当前模型列表里只有插件自带的模型。',
|
|
243
|
+
modelsRouteMissing: '宿主没有提供 /vrmpet3d/models,模型导入在这个版本里不可用。',
|
|
244
|
+
readModelsFailed: '读取已导入模型失败:宿主没有提供 /vrmpet3d/models。',
|
|
245
|
+
importFailedHttp: '导入失败(HTTP {status})',
|
|
246
|
+
persistFailedClause: '导入已生效,但没能写入清单文件({reason}),重启后会丢失。',
|
|
247
|
+
unknownReason: '未知原因',
|
|
248
|
+
writeModelsFailed: '写入失败:宿主没有提供 /vrmpet3d/models。',
|
|
249
|
+
noFilePicker: '这个窗口没有文件选择器(不是在 DSH 桌面版里打开的),请在下面直接输入模型文件的路径。',
|
|
250
|
+
motionImportedValue: '{name}({clips} 支)',
|
|
251
|
+
importedMotionsNote: '已导入 {motions} 个动作文件夹{legacy},当前可选 {clips} 支动作。',
|
|
252
|
+
onlyBundledMotions: '当前只有插件自带的动作包。',
|
|
253
|
+
motionsRouteMissing: '宿主没有提供 /vrmpet3d/motions,动作导入在这个版本里不可用。',
|
|
254
|
+
readMotionsFailed: '读取已导入动作失败:宿主没有提供 /vrmpet3d/motions。',
|
|
255
|
+
writeMotionsFailed: '写入失败:宿主没有提供 /vrmpet3d/motions。',
|
|
256
|
+
noFolderPicker: '这个窗口没有文件夹选择器(不是在 DSH 桌面版里打开的),请在下面直接输入动作文件夹的路径。',
|
|
257
|
+
infoFailed: '未能读取模型信息:',
|
|
258
|
+
noLicence: '文件里没有授权信息',
|
|
259
|
+
licenceName: '名称:',
|
|
260
|
+
licenceAuthor: '作者:',
|
|
261
|
+
licencePermission: '使用许可:',
|
|
262
|
+
licenceModification: '修改:',
|
|
263
|
+
licenceCommercial: '商用:',
|
|
264
|
+
licenceCredit: '署名:',
|
|
265
|
+
licenceLicence: '协议:',
|
|
266
|
+
noModel: '没有模型',
|
|
267
|
+
noModelChosen: '当前没有选择模型:宠物不会显示任何东西。上面选一个,设置会立刻生效。',
|
|
268
|
+
notVrm: '非 VRM/GLB',
|
|
269
|
+
meshUnit: ' 网格 / ',
|
|
270
|
+
materialUnit: ' 材质 / ',
|
|
271
|
+
imageUnit: ' 贴图',
|
|
272
|
+
humanoidUnit: ' 人形骨',
|
|
273
|
+
lookAtUnit: '视线:',
|
|
274
|
+
triggerIdle: '空闲',
|
|
275
|
+
triggerDrag: '拖动时',
|
|
276
|
+
triggerClick: '点击时',
|
|
277
|
+
triggerHold: '长按时',
|
|
278
|
+
triggerToggleTitle: '关掉这一行,这个情境就不再触发情绪',
|
|
279
|
+
noExpressions: '这个模型没有可用的表情',
|
|
280
|
+
none: '无',
|
|
281
|
+
selectedExpressions: '已选 {n} 个表情;点开可多选',
|
|
282
|
+
noneRowTitle: '无:这一行不显示任何表情(默认)',
|
|
283
|
+
noneBoxTitle: '不显示任何表情(这一行的默认)',
|
|
284
|
+
holdSecondsTitle: '0 = 一直保持到下一次变化',
|
|
285
|
+
heldSuffix: '(保持)',
|
|
286
|
+
partLabelTitle: '这只宠物对这个部位的说法,随便取',
|
|
287
|
+
regionDefault: '默认',
|
|
288
|
+
partRemove: '删除',
|
|
289
|
+
partRemoveTitle: '删掉这个部位:它的结构会回落到按名字判断的默认部位',
|
|
290
|
+
partLinesAiPlaceholder: '碎碎念:一行一句;留空则由 AI 按这个部位的提示词生成',
|
|
291
|
+
partLinesPlaceholder: '碎碎念:一行一句,摸到这个部位时随机说一句',
|
|
292
|
+
partPromptPlaceholder: 'AI 提示词:切到「AI 碎碎念」时用它生成这个部位的一句话',
|
|
293
|
+
matchExact: '精确',
|
|
294
|
+
matchPrefixWord: '前缀',
|
|
295
|
+
matchExactTitle: '只匹配这个名字本身;点一下变成前缀匹配',
|
|
296
|
+
matchPrefixTitle: '匹配这个名字和它下面的一切(LiuHai.L.002 也算);点一下变成只匹配它本身',
|
|
297
|
+
chipDropTitle: '不再让这个部位拥有这个结构',
|
|
298
|
+
unrecognised: '未识别',
|
|
299
|
+
addStructurePlaceholder: '结构名,回车添加',
|
|
300
|
+
addStructureTitle: '直接输入模型里的结构名,回车添加',
|
|
301
|
+
browse: '浏览…',
|
|
302
|
+
browseTitle: '浏览这个模型的所有结构名(可筛选)',
|
|
303
|
+
addedSuffix: ' · 已加入',
|
|
304
|
+
structuresCount: '{n} / {total} 个结构',
|
|
305
|
+
newPart: '新部位',
|
|
306
|
+
springStiffness: '刚度',
|
|
307
|
+
springDrag: '阻尼',
|
|
308
|
+
springGravity: '重力',
|
|
309
|
+
colliderGroupPrefix: '组 ',
|
|
310
|
+
unnamedBone: '(未命名骨骼)',
|
|
311
|
+
shapeSphere: '球',
|
|
312
|
+
shapeWord: '形状',
|
|
313
|
+
colliderRadiusTitle: '碰撞球半径(模型单位):越大越挡得住,越小越容易被穿过',
|
|
314
|
+
noSprings: '这个模型没有弹簧骨。',
|
|
315
|
+
chainsTotal: '共 {chains} 条链、{joints} 个可调 joint。',
|
|
316
|
+
expandChains: '展开全部 {n} 条弹簧骨链',
|
|
317
|
+
unnamedChainSuffix: '(未命名)',
|
|
318
|
+
unnamedChainPrefix: '未命名链 #',
|
|
319
|
+
entriesListed: '(文件列了 {n} 项)',
|
|
320
|
+
colliderGroupsNote: ' · {n} 碰撞组',
|
|
321
|
+
modelValuePrefix: '模型原值 ',
|
|
322
|
+
packDefaultPrefix: ' · 随包默认 {n}',
|
|
323
|
+
stateDefault: '默认',
|
|
324
|
+
stateChanged: '已改',
|
|
325
|
+
resetChain: '本组复位',
|
|
326
|
+
compressedTag: ' · 已压缩',
|
|
327
|
+
once: '单次',
|
|
328
|
+
loop: '循环',
|
|
329
|
+
dancingOnceTitle: '跳舞一律播放一次,播完回到空闲',
|
|
330
|
+
clipExpressionTitle: '这支动作播放时显示的表情(覆盖情绪表的挑选)',
|
|
331
|
+
clipMouthTitle: '这支动作播放时的口型(覆盖表情自带的口型)',
|
|
332
|
+
followMouth: '口型:跟随表情',
|
|
333
|
+
followExpression: '表情:跟随情绪',
|
|
334
|
+
blankMouth: '口型:默认(无口型)',
|
|
335
|
+
blankExpression: '表情:默认(无表情)',
|
|
336
|
+
mouthPrefix: '口型:',
|
|
337
|
+
expressionPrefix: '表情:',
|
|
338
|
+
motionCount: '({offered} 支可选{active})',
|
|
339
|
+
motionCountActive: ' · 生效 {n} 支',
|
|
340
|
+
bundledNote: '其中 {n} 支随插件分发(assets/motions),不需要导入。',
|
|
341
|
+
packedNote: '其中 {n} 支是压缩存放(.br),播放时自动解压到内存,体积按压缩后的文件计。',
|
|
342
|
+
packedAllNote: '这 {n} 支全部是压缩存放(.br),播放时自动解压到内存,体积按压缩后的文件计。',
|
|
343
|
+
noMotionFiles: '没有可用的动作文件。',
|
|
344
|
+
motionStateCount: '已选 {n} 支',
|
|
345
|
+
motionStateDefaultCount: '随包默认 {n} 支',
|
|
346
|
+
notPlaying: '不播放(已清空)',
|
|
347
|
+
proceduralIdle: '程序化待机',
|
|
348
|
+
folderClips: '{n} 支',
|
|
349
|
+
everyLabel: '单次动作间隔',
|
|
350
|
+
oneShotHere: '(本状态有 {n} 支单次动作)',
|
|
351
|
+
noOneShot: '(本状态还没有勾选「单次」动作,不会触发)',
|
|
352
|
+
folderEmpty: 'assets/motions/{folders} 里还没有 .vrma:把面向这个状态的动作放进那个目录,它们会立刻出现在这里。',
|
|
353
|
+
clipModeTitle: '这支动作在这个状态里播放一次还是循环',
|
|
354
|
+
clipSpeedTitle: '这支动作在这个状态里的播放速度(百分比)',
|
|
355
|
+
speed: '速度',
|
|
356
|
+
clipGazeTitle: '这支动作在这个状态里播放时,是否看向鼠标(关掉就是不看)',
|
|
357
|
+
gazeWord: '凝视',
|
|
358
|
+
notInCatalog: '(不在动作目录中)',
|
|
359
|
+
statusText: '状态文字',
|
|
360
|
+
defaultParen: '(默认)',
|
|
361
|
+
noTextParen: '(不显示文字)',
|
|
362
|
+
statusTextTitle: '这个状态下宠物头顶显示的文字;清空则什么都不显示',
|
|
363
|
+
statusTagTitle: '文字后面的标记',
|
|
364
|
+
noTag: '无标记',
|
|
365
|
+
statusAnimationTitle: '蓝渐变动画就是 DSH 自己的「深度求索中」扫光效果',
|
|
366
|
+
animStatic: '静态',
|
|
367
|
+
animShimmer: '蓝渐变动画',
|
|
368
|
+
waitingDurationTitle: '等待确认不受时间影响:一直显示到你回答完问题 / 确认完权限',
|
|
369
|
+
noticeDurationTitle: '至少显示多久;之后一直留到你切回这个会话(默认 4.2 秒是下限)',
|
|
370
|
+
normalDurationTitle: '这个状态下文字显示多久;0 表示一直显示到状态结束',
|
|
371
|
+
waitingUnit: '秒(此状态不受时间影响)',
|
|
372
|
+
untimedUnit: '秒(至少)',
|
|
373
|
+
normalUnit: '秒(0 = 始终)',
|
|
374
|
+
capWarning: '每个状态最多勾选 {cap} 支动作:{state} 只保留了前 {kept} 支。',
|
|
375
|
+
stateResting: '空闲',
|
|
376
|
+
stateWorking: '工作中',
|
|
377
|
+
stateWaiting: '等待确认',
|
|
378
|
+
stateDone: '任务完成',
|
|
379
|
+
stateError: '出错',
|
|
380
|
+
stateOffline: '离线',
|
|
381
|
+
stateHeld: '拖动中',
|
|
382
|
+
stateFalling: '抛掷 / 下落中',
|
|
383
|
+
stateLanding: '落地',
|
|
384
|
+
stateDancing: '跳舞',
|
|
385
|
+
speechStateResting: '空闲 / 静息态',
|
|
386
|
+
emptyLinesBuiltin: '用内置短语',
|
|
387
|
+
emptyLinesAi: '用内置 AI 生成提示词',
|
|
388
|
+
emptyLinesPlaceholderBuiltin: '留空则用内置短语',
|
|
389
|
+
emptyLinesPlaceholderAi: '留空则用内置 AI 提示词生成一句(生成期间先显示内置短语)',
|
|
390
|
+
linesLabel: '台词(一行一条)',
|
|
391
|
+
promptLabel: 'AI 生成提示词',
|
|
392
|
+
defaultSpeechPrompt: '现在说一句。',
|
|
393
|
+
lineCount: '{n} 条',
|
|
394
|
+
llmAvailable: 'DSH 的 LLM 服务可用({n} 个 provider)',
|
|
395
|
+
llmUnavailable: '这个 DSH 没有可用的 LLM 服务,AI 碎碎念只能用自定义 Provider',
|
|
396
|
+
noProviders: '还没有配置任何 provider',
|
|
397
|
+
providerNoModels: '这个 provider 没有返回任何模型',
|
|
398
|
+
readFailedPrefix: '读取失败:',
|
|
399
|
+
customProviderNote: '自定义 Provider 用 OpenAI 兼容的 /chat/completions。',
|
|
400
|
+
noteJoin: ';',
|
|
401
|
+
llmNoAnswer: '读不到 provider 列表(宿主没有回应)。',
|
|
402
|
+
generating: '生成中…',
|
|
403
|
+
failedPrefix: '失败:',
|
|
404
|
+
hostNoAnswer: '宿主没有回应',
|
|
405
|
+
quoteOpen: '「',
|
|
406
|
+
quoteClose: '」',
|
|
407
|
+
msParen: '({ms} ms)',
|
|
408
|
+
straightLine: '(直线)',
|
|
409
|
+
globalRadius: '(全局)',
|
|
410
|
+
floatingUnavailable: '悬浮窗不可用(宿主没有提供 /vrmpet3d/desktop);app 内的宠物仍会跟随这些设置。',
|
|
411
|
+
statusModel: '模型 {model}(两个界面共用)',
|
|
412
|
+
statusWindow: '悬浮窗 {state}',
|
|
413
|
+
windowRunning: '运行中',
|
|
414
|
+
windowOff: '未开启',
|
|
415
|
+
windowRenderedReady: '悬浮窗运行中(已渲染,模型 {model})',
|
|
416
|
+
windowRenderedNotReady: '悬浮窗运行中(页面还没就绪)',
|
|
417
|
+
windowNotOpen: '悬浮窗未开启',
|
|
418
|
+
currentModelLine: '当前模型:{model} · {window} · app 内宠物同样跟随设置',
|
|
419
|
+
spanNow: '(当前 {px} px)',
|
|
420
|
+
noScreenSize: '(宿主没有报告屏幕尺寸)',
|
|
421
|
+
statusReadFailed: '未能读取悬浮窗状态;改动仍会写入设置。',
|
|
422
|
+
gazeNotRunning: '悬浮窗未开启:看不到实时数值,但设置已经写入,app 内的宠物同样使用它。',
|
|
423
|
+
gazeNoReport: '悬浮窗还没有报告视线数值(重启悬浮窗后这里会显示它实际使用的曲率与朝向)。',
|
|
424
|
+
gazeApplied: '渲染器正在使用:曲率 {curve} · 满偏 {spanX}×{spanY} px · 当前视线 {aimX}, {aimY}(每 2 秒读取一次;如果它随滑杆变化,就说明改动已经到达渲染器)',
|
|
425
|
+
setupOpenedTab: '已在新标签页里打开引导。',
|
|
426
|
+
setupFallbackTab: '启动器没能打开窗口,改在新标签页里打开。',
|
|
427
|
+
setupOpenedWindow: '引导已在新窗口里打开。',
|
|
428
|
+
setupNoHost: '宿主没有提供 /vrmpet3d/desktop。',
|
|
429
|
+
settingsUnsupported: '当前宿主没有 /vrmpet3d/settings:设置只保存在这个页面所在的存储里。',
|
|
430
|
+
},
|
|
431
|
+
en: {
|
|
432
|
+
// ---- the chrome, and the name each page gives itself ----
|
|
433
|
+
titleModel: 'vrmpet3d · Model parameters',
|
|
434
|
+
gazePitchUp: 'Maximum upward pitch', gazePitchDown: 'Maximum downward pitch', gazePitchTest: 'Try 20° / 30°',
|
|
435
|
+
gazeCurveMode: 'Curve mode', gazeAxes: 'Separate axes', gazeRadial: '2D distance',
|
|
436
|
+
gazeSpatial: 'Spatial angles', gazeCurveSpatial: 'Spatial angle curve', gazeDistance: 'Target distance',
|
|
437
|
+
gazeSpatialHint: 'Distance is in span units; larger values soften head turns. At a fixed height, moving sideways reduces pitch. At eye level, pitch is zero. Defaults: curve 1, target distance 0.1.',
|
|
438
|
+
gazeSpanHint: 'Enter 0 for automatic spans: both axes use half the screen width.',
|
|
439
|
+
gazeCurveX: 'Horizontal curve', gazeCurveY: 'Vertical curve', gazeCurveRadial: '2D distance curve',
|
|
440
|
+
gazeCurveHint: 'Lower values increase sensitivity near the center; 1 is linear. The 2D mode uses distance normalized by each axis span, reducing abrupt pitch changes as a distant pointer crosses eye level. Each mode keeps its own values.',
|
|
441
|
+
gazePitchHint: 'Automatic: 60° up and down. Range: 0–60°. Zero disables tracking in that direction. Gaze adds these angles while preserving the animation’s head pose.',
|
|
442
|
+
toonTitle: 'Cartoon enhancement', toonState: 'Material status', toonOriginal: 'Model original', toonReset: 'Restore original materials',
|
|
443
|
+
toonNote: 'Saved for this model only. Leave a value blank to use its original. Turning MToon off uses standard lit materials. Outline scale adjusts existing outlines only.',
|
|
444
|
+
toonLoading: 'Reading model materials…', toonUnsupported: 'This model has no MToon materials. Conversion is not supported yet.',
|
|
445
|
+
toonStatus: '{n}/{total} native MToon materials · Currently {state}', toonEnabled: 'on', toonDisabled: 'off',
|
|
446
|
+
toonToony: 'Shading hardness', toonShift: 'Shading shift', toonShade: 'Shade strength', toonOutline: 'Outline scale',
|
|
447
|
+
toonMixed: 'Original (varies by material)', toonNoOutline: 'This model has no enabled outlines',
|
|
448
|
+
titleGlobal: 'vrmpet3d · General settings',
|
|
449
|
+
headingModel: 'Model parameters',
|
|
450
|
+
headingGlobal: 'General settings',
|
|
451
|
+
loading: 'Loading settings…',
|
|
452
|
+
reading: 'Reading…',
|
|
453
|
+
navSections: 'Settings sections',
|
|
454
|
+
// ---- the section names, which are also the nav entries ----
|
|
455
|
+
sectionModel: 'Model',
|
|
456
|
+
sectionExpression: 'Expressions',
|
|
457
|
+
sectionParts: 'Body parts',
|
|
458
|
+
sectionSprings: 'Spring bones',
|
|
459
|
+
sectionDisplay: 'Display',
|
|
460
|
+
sectionGaze: 'Gaze',
|
|
461
|
+
sectionMouse: 'Pointer physics',
|
|
462
|
+
sectionState: 'States and clips',
|
|
463
|
+
sectionSpeech: 'Chit-chat',
|
|
464
|
+
sectionModelImport: 'Model import',
|
|
465
|
+
sectionMotionImport: 'Motion import',
|
|
466
|
+
sectionOther: 'Other',
|
|
467
|
+
// ---- words a row repeats ----
|
|
468
|
+
on: 'On',
|
|
469
|
+
reset: 'Reset',
|
|
470
|
+
close: 'Close',
|
|
471
|
+
auto: 'Auto',
|
|
472
|
+
secondsUnit: 's',
|
|
473
|
+
remove: 'Remove',
|
|
474
|
+
// ---- 界面语言 (the language row) ----
|
|
475
|
+
language: 'Interface language',
|
|
476
|
+
languageAuto: 'Auto',
|
|
477
|
+
languageZh: '中文',
|
|
478
|
+
languageEn: 'English',
|
|
479
|
+
// ---- the model page: model and expressions ----
|
|
480
|
+
currentModel: 'Current model',
|
|
481
|
+
availableInfo: 'Details',
|
|
482
|
+
blink: 'Blink',
|
|
483
|
+
interval: 'Interval',
|
|
484
|
+
emotion: 'Emotion',
|
|
485
|
+
idleInterval: 'Idle interval',
|
|
486
|
+
colSituation: 'Situation',
|
|
487
|
+
colUse: 'Use',
|
|
488
|
+
colExpression: 'Expression',
|
|
489
|
+
colHold: 'Duration',
|
|
490
|
+
// ---- the model page: body parts ----
|
|
491
|
+
partsExpand: 'Show the part list',
|
|
492
|
+
colPart: 'Part',
|
|
493
|
+
colStructure: 'Structure',
|
|
494
|
+
partsAdd: 'Add part',
|
|
495
|
+
partsMaxPrefix: 'at most',
|
|
496
|
+
partsMaxSuffix: '',
|
|
497
|
+
filterStructures: 'Filter structure names',
|
|
498
|
+
// ---- the model page: spring bones ----
|
|
499
|
+
springMotionWhile: 'While a clip plays',
|
|
500
|
+
springMotionBox: 'Hair and skirt keep swinging',
|
|
501
|
+
springColliders: 'Spring-bone collisions',
|
|
502
|
+
collidersOff: 'All off, which is the default',
|
|
503
|
+
collidersAll: 'All on',
|
|
504
|
+
collidersCustom: 'Custom',
|
|
505
|
+
collidersDetail: 'Per collider',
|
|
506
|
+
springResetAll: 'Reset everything',
|
|
507
|
+
// ---- 通用设置: display ----
|
|
508
|
+
petSurface: 'Pet surface',
|
|
509
|
+
surfaceApp: 'Inside the app',
|
|
510
|
+
sizeApp: 'In-app size',
|
|
511
|
+
sizeWindow: 'Window size',
|
|
512
|
+
idleNap: 'Idle nap',
|
|
513
|
+
sleep30: '30 s',
|
|
514
|
+
sleep120: '2 min',
|
|
515
|
+
sleep300: '5 min',
|
|
516
|
+
debugMode: 'Debug mode',
|
|
517
|
+
debugModeBox: 'Show the floating and in-app pets together',
|
|
518
|
+
view: 'View',
|
|
519
|
+
viewReset: 'Reset view',
|
|
520
|
+
pet: 'Pet',
|
|
521
|
+
hide: 'Hide',
|
|
522
|
+
// ---- 通用设置: gaze ----
|
|
523
|
+
lookAtPointer: 'Look at the cursor',
|
|
524
|
+
curve: 'Curve',
|
|
525
|
+
span: 'Full deflection',
|
|
526
|
+
pxHorizontal: 'px horizontal',
|
|
527
|
+
pxVertical: 'px vertical',
|
|
528
|
+
gazeIdle: 'Stop while still',
|
|
529
|
+
readingWindow: 'Reading the floating window…',
|
|
530
|
+
// ---- 通用设置: pointer physics ----
|
|
531
|
+
springPhysics: 'Spring-bone physics',
|
|
532
|
+
windPower: 'Stroke strength',
|
|
533
|
+
windRadius: 'Radius',
|
|
534
|
+
windDecay: 'Afterglow',
|
|
535
|
+
windSpeed: 'Full-force speed',
|
|
536
|
+
pxPerSecond: 'px/s',
|
|
537
|
+
// ---- 通用设置: states and clips ----
|
|
538
|
+
playMotion: 'Play clips',
|
|
539
|
+
// ---- 通用设置: chit-chat, the markup ----
|
|
540
|
+
speechDisclaimer: '<strong>Chit-chat is a joke — do not take it seriously.</strong> It never touches your project or files, and it does not interfere with DSH; on a slow machine the 3D rendering and spring-bone physics may cost a little performance, so closing the pet or making it smaller helps.',
|
|
541
|
+
speechEnable: 'Enable',
|
|
542
|
+
speechTalk: 'Talk',
|
|
543
|
+
speechHold: 'Show each line',
|
|
544
|
+
speechGap: 'Shortest gap',
|
|
545
|
+
speechIdleInterval: 'Idle murmur interval',
|
|
546
|
+
speechWorkInterval: 'Working murmur interval',
|
|
547
|
+
speechSource: 'Line source',
|
|
548
|
+
sourceBuiltin: 'Built-in phrases',
|
|
549
|
+
sourceCustom: 'Custom phrases',
|
|
550
|
+
sourceAi: 'AI generated',
|
|
551
|
+
speechAiFrom: 'AI source',
|
|
552
|
+
providerDsh: 'DSH Provider',
|
|
553
|
+
providerCustom: 'Custom provider',
|
|
554
|
+
speechModel: 'Model',
|
|
555
|
+
speechKeyClear: 'Clear',
|
|
556
|
+
persona: 'Persona',
|
|
557
|
+
temperature: 'Temperature',
|
|
558
|
+
timeout: 'Timeout',
|
|
559
|
+
speechTest: 'Try a line',
|
|
560
|
+
generate: 'Generate',
|
|
561
|
+
// ---- 通用设置: imports ----
|
|
562
|
+
importModel: 'Import model',
|
|
563
|
+
pickModelFile: 'Choose a model file…',
|
|
564
|
+
manualPath: 'Type a path',
|
|
565
|
+
filePath: 'File path',
|
|
566
|
+
import: 'Import',
|
|
567
|
+
importFolder: 'Import folder',
|
|
568
|
+
pickMotionDir: 'Choose a folder…',
|
|
569
|
+
path: 'Path',
|
|
570
|
+
add: 'Add',
|
|
571
|
+
// ---- 通用设置: other ----
|
|
572
|
+
setupSection: 'First-run guide',
|
|
573
|
+
setupRerun: 'Run the guide again',
|
|
574
|
+
// ---- the script's own words ----
|
|
575
|
+
writeFailed: 'Could not write the settings: ',
|
|
576
|
+
readTimeout: 'Timed out reading the settings: the host did not answer within 8 seconds; everything else can still be retried by hand.',
|
|
577
|
+
catalogFailed: 'Could not read the model list: the host does not serve /vrmpet3d/catalog',
|
|
578
|
+
importTag: 'Imported',
|
|
579
|
+
legacyDir: 'Legacy folder',
|
|
580
|
+
importedModelsNote: 'Imported {models} model file(s){legacy}; the model list now holds {total} models.',
|
|
581
|
+
legacyDirsClause: ' and {dirs} legacy folder(s)',
|
|
582
|
+
onlyBundledModels: 'The model list holds only the plugin bundled models.',
|
|
583
|
+
modelsRouteMissing: 'The host does not serve /vrmpet3d/models, so model import is unavailable in this build.',
|
|
584
|
+
readModelsFailed: 'Could not read the imported models: the host does not serve /vrmpet3d/models.',
|
|
585
|
+
importFailedHttp: 'Import failed, HTTP {status}',
|
|
586
|
+
persistFailedClause: 'The import took effect, but the list file could not be written ({reason}); it will be lost on restart.',
|
|
587
|
+
unknownReason: 'unknown reason',
|
|
588
|
+
writeModelsFailed: 'Write failed: the host does not serve /vrmpet3d/models.',
|
|
589
|
+
noFilePicker: 'This window has no file chooser, because it is not open inside DSH Desktop, so type the model file path below.',
|
|
590
|
+
motionImportedValue: '{name} — {clips} clips',
|
|
591
|
+
importedMotionsNote: 'Imported {motions} motion folder(s){legacy}; {clips} clips are available.',
|
|
592
|
+
onlyBundledMotions: 'Only the plugin bundled motion pack is present.',
|
|
593
|
+
motionsRouteMissing: 'The host does not serve /vrmpet3d/motions, so motion import is unavailable in this build.',
|
|
594
|
+
readMotionsFailed: 'Could not read the imported motions: the host does not serve /vrmpet3d/motions.',
|
|
595
|
+
writeMotionsFailed: 'Write failed: the host does not serve /vrmpet3d/motions.',
|
|
596
|
+
noFolderPicker: 'This window has no folder chooser, because it is not open inside DSH Desktop, so type the motion folder path below.',
|
|
597
|
+
infoFailed: 'Could not read the model info: ',
|
|
598
|
+
noLicence: 'no licence information in the file',
|
|
599
|
+
licenceName: 'Name: ',
|
|
600
|
+
licenceAuthor: 'Author: ',
|
|
601
|
+
licencePermission: 'Usage permission: ',
|
|
602
|
+
licenceModification: 'Modification: ',
|
|
603
|
+
licenceCommercial: 'Commercial use: ',
|
|
604
|
+
licenceCredit: 'Credit: ',
|
|
605
|
+
licenceLicence: 'Licence: ',
|
|
606
|
+
noModel: 'No model',
|
|
607
|
+
noModelChosen: 'No model is selected: the pet will draw nothing. Pick one above and the change takes effect at once.',
|
|
608
|
+
notVrm: 'Not VRM/GLB',
|
|
609
|
+
meshUnit: ' meshes / ',
|
|
610
|
+
materialUnit: ' materials / ',
|
|
611
|
+
imageUnit: ' textures',
|
|
612
|
+
humanoidUnit: ' humanoid bones',
|
|
613
|
+
lookAtUnit: 'Look-at: ',
|
|
614
|
+
triggerIdle: 'Idle',
|
|
615
|
+
triggerDrag: 'While dragged',
|
|
616
|
+
triggerClick: 'On click',
|
|
617
|
+
triggerHold: 'On long press',
|
|
618
|
+
triggerToggleTitle: 'Untick this row and the situation stops triggering emotions',
|
|
619
|
+
noExpressions: 'This model has no usable expressions',
|
|
620
|
+
none: 'None',
|
|
621
|
+
selectedExpressions: 'Selected {n}; open to pick several',
|
|
622
|
+
noneRowTitle: 'None: this row shows no expression, which is the default',
|
|
623
|
+
noneBoxTitle: 'Show no expression: the default for this row',
|
|
624
|
+
holdSecondsTitle: '0 = hold until the next change',
|
|
625
|
+
heldSuffix: ' (held)',
|
|
626
|
+
partLabelTitle: 'What this pet calls this part; take any name you like',
|
|
627
|
+
regionDefault: 'default',
|
|
628
|
+
partRemove: 'Delete',
|
|
629
|
+
partRemoveTitle: 'Delete this part: its structures fall back to the name-based default region',
|
|
630
|
+
partLinesAiPlaceholder: 'Chit-chat: one line each; leave it empty and the AI writes from this part prompt',
|
|
631
|
+
partLinesPlaceholder: 'Chit-chat: one line each, said at random when this part is touched',
|
|
632
|
+
partPromptPlaceholder: 'AI prompt: used to generate one line about this part when AI chit-chat is on',
|
|
633
|
+
matchExact: 'Exact',
|
|
634
|
+
matchPrefixWord: 'Prefix',
|
|
635
|
+
matchExactTitle: 'Matches this name alone; click to switch to prefix matching',
|
|
636
|
+
matchPrefixTitle: 'Matches this name and everything under it, LiuHai.L.002 included; click to match the name alone',
|
|
637
|
+
chipDropTitle: 'Stop this part from owning this structure',
|
|
638
|
+
unrecognised: 'unrecognised',
|
|
639
|
+
addStructurePlaceholder: 'Structure name, Enter to add',
|
|
640
|
+
addStructureTitle: 'Type a structure name from the model and press Enter',
|
|
641
|
+
browse: 'Browse…',
|
|
642
|
+
browseTitle: 'Browse every structure name in this model, filterable',
|
|
643
|
+
addedSuffix: ' · added',
|
|
644
|
+
structuresCount: '{n} / {total} structures',
|
|
645
|
+
newPart: 'New part',
|
|
646
|
+
springStiffness: 'Stiffness',
|
|
647
|
+
springDrag: 'Damping',
|
|
648
|
+
springGravity: 'Gravity',
|
|
649
|
+
colliderGroupPrefix: 'group ',
|
|
650
|
+
unnamedBone: 'unnamed bone',
|
|
651
|
+
shapeSphere: 'sphere',
|
|
652
|
+
shapeWord: 'shape',
|
|
653
|
+
colliderRadiusTitle: 'Collider radius in model units: larger blocks more, smaller is easier to pass through',
|
|
654
|
+
noSprings: 'This model has no spring bones.',
|
|
655
|
+
chainsTotal: '{chains} chains, {joints} adjustable joints.',
|
|
656
|
+
expandChains: 'Show all {n} spring-bone chains',
|
|
657
|
+
unnamedChainSuffix: ' (unnamed)',
|
|
658
|
+
unnamedChainPrefix: 'Unnamed chain #',
|
|
659
|
+
entriesListed: 'the file lists {n}',
|
|
660
|
+
colliderGroupsNote: ' · {n} collider groups',
|
|
661
|
+
modelValuePrefix: 'file value ',
|
|
662
|
+
packDefaultPrefix: ' · pack default {n}',
|
|
663
|
+
stateDefault: 'Default',
|
|
664
|
+
stateChanged: 'Changed',
|
|
665
|
+
resetChain: 'Reset chain',
|
|
666
|
+
compressedTag: ' · compressed',
|
|
667
|
+
once: 'Once',
|
|
668
|
+
loop: 'Loop',
|
|
669
|
+
dancingOnceTitle: 'Dancing always plays once and then returns to idle',
|
|
670
|
+
clipExpressionTitle: 'The expression shown while this clip plays, overriding the mood table',
|
|
671
|
+
clipMouthTitle: 'The mouth shape while this clip plays, overriding the expression own',
|
|
672
|
+
followMouth: 'Mouth: follow the expression',
|
|
673
|
+
followExpression: 'Expression: follow the mood',
|
|
674
|
+
blankMouth: 'Mouth: default, no mouth shape',
|
|
675
|
+
blankExpression: 'Expression: default, no expression',
|
|
676
|
+
mouthPrefix: 'Mouth: ',
|
|
677
|
+
expressionPrefix: 'Expression: ',
|
|
678
|
+
motionCount: '{offered} clips offered{active}',
|
|
679
|
+
motionCountActive: ' · active {n}',
|
|
680
|
+
bundledNote: '{n} of them ship with the plugin under assets/motions and need no import.',
|
|
681
|
+
packedNote: '{n} of them are stored compressed as .br and unpacked into memory on playback; the size shown is the compressed file.',
|
|
682
|
+
packedAllNote: 'All {n} clips are stored compressed as .br and unpacked into memory on playback; the size shown is the compressed file.',
|
|
683
|
+
noMotionFiles: 'There are no usable motion files.',
|
|
684
|
+
motionStateCount: 'Selected {n}',
|
|
685
|
+
motionStateDefaultCount: 'Pack default {n}',
|
|
686
|
+
notPlaying: 'Nothing plays, cleared',
|
|
687
|
+
proceduralIdle: 'Procedural idle',
|
|
688
|
+
folderClips: '{n} clips',
|
|
689
|
+
everyLabel: 'One-shot interval',
|
|
690
|
+
oneShotHere: '{n} one-shot clips in this state',
|
|
691
|
+
noOneShot: 'no one-shot clip is ticked here, so this never fires',
|
|
692
|
+
folderEmpty: 'assets/motions/{folders} holds no .vrma yet: drop clips for this state into that folder and they appear here at once.',
|
|
693
|
+
clipModeTitle: 'Whether this clip plays once or loops in this state',
|
|
694
|
+
clipSpeedTitle: 'Playback speed for this clip in this state, in percent',
|
|
695
|
+
speed: 'Speed',
|
|
696
|
+
clipGazeTitle: 'Whether the pet looks at the pointer while this clip plays in this state',
|
|
697
|
+
gazeWord: 'Gaze',
|
|
698
|
+
notInCatalog: ' (not in the motion catalogue)',
|
|
699
|
+
statusText: 'Status text',
|
|
700
|
+
defaultParen: ' (default)',
|
|
701
|
+
noTextParen: '(no text)',
|
|
702
|
+
statusTextTitle: 'The text shown over the pet in this state; clearing it shows nothing',
|
|
703
|
+
statusTagTitle: 'The marker after the text',
|
|
704
|
+
noTag: 'No marker',
|
|
705
|
+
statusAnimationTitle: 'The blue shimmer is DSH own working sweep effect',
|
|
706
|
+
animStatic: 'Static',
|
|
707
|
+
animShimmer: 'Blue shimmer',
|
|
708
|
+
waitingDurationTitle: 'Waiting is untimed: it stays until you answer the question or confirm the permission',
|
|
709
|
+
noticeDurationTitle: 'How long it shows at least; it then stays until you switch back to that session, and 4.2 s is the shipped minimum',
|
|
710
|
+
normalDurationTitle: 'How long the text shows in this state; 0 means until the state ends',
|
|
711
|
+
waitingUnit: 's, this state ignores the clock',
|
|
712
|
+
untimedUnit: 's, minimum',
|
|
713
|
+
normalUnit: 's, 0 = always',
|
|
714
|
+
capWarning: 'At most {cap} clips per state: {state} kept the first {kept}.',
|
|
715
|
+
stateResting: 'Idle',
|
|
716
|
+
stateWorking: 'Working',
|
|
717
|
+
stateWaiting: 'Waiting for you',
|
|
718
|
+
stateDone: 'Task complete',
|
|
719
|
+
stateError: 'Error',
|
|
720
|
+
stateOffline: 'Offline',
|
|
721
|
+
stateHeld: 'Being dragged',
|
|
722
|
+
stateFalling: 'Thrown / falling',
|
|
723
|
+
stateLanding: 'Landing',
|
|
724
|
+
stateDancing: 'Dancing',
|
|
725
|
+
speechStateResting: 'Idle / resting',
|
|
726
|
+
emptyLinesBuiltin: 'uses the built-in phrases',
|
|
727
|
+
emptyLinesAi: 'generated from the built-in AI prompt',
|
|
728
|
+
emptyLinesPlaceholderBuiltin: 'Leave empty to use the built-in phrases',
|
|
729
|
+
emptyLinesPlaceholderAi: 'Leave empty to generate a line from the built-in AI prompt; the built-in phrase shows while it generates',
|
|
730
|
+
linesLabel: 'Lines, one per line',
|
|
731
|
+
promptLabel: 'AI generation prompt',
|
|
732
|
+
defaultSpeechPrompt: 'Say a line now.',
|
|
733
|
+
lineCount: '{n} lines',
|
|
734
|
+
llmAvailable: 'DSH LLM service is available, {n} providers',
|
|
735
|
+
llmUnavailable: 'This DSH has no usable LLM service, so AI chit-chat can only use a custom provider',
|
|
736
|
+
noProviders: 'no provider is configured yet',
|
|
737
|
+
providerNoModels: 'this provider returned no models',
|
|
738
|
+
readFailedPrefix: 'Read failed: ',
|
|
739
|
+
customProviderNote: 'A custom provider uses an OpenAI-compatible /chat/completions.',
|
|
740
|
+
noteJoin: '; ',
|
|
741
|
+
llmNoAnswer: 'Could not read the provider list; the host did not answer.',
|
|
742
|
+
generating: 'Generating…',
|
|
743
|
+
failedPrefix: 'Failed: ',
|
|
744
|
+
hostNoAnswer: 'the host did not answer',
|
|
745
|
+
quoteOpen: '“',
|
|
746
|
+
quoteClose: '”',
|
|
747
|
+
msParen: '{ms} ms',
|
|
748
|
+
straightLine: ' (straight)',
|
|
749
|
+
globalRadius: ' (global)',
|
|
750
|
+
floatingUnavailable: 'The floating window is unavailable: the host does not serve /vrmpet3d/desktop. The in-app pet still follows these settings.',
|
|
751
|
+
statusModel: 'Model {model}, shared by both surfaces',
|
|
752
|
+
statusWindow: 'Window {state}',
|
|
753
|
+
windowRunning: 'running',
|
|
754
|
+
windowOff: 'not open',
|
|
755
|
+
windowRenderedReady: 'The floating window is running, rendered, model {model}',
|
|
756
|
+
windowRenderedNotReady: 'The floating window is running, but the page is not ready yet',
|
|
757
|
+
windowNotOpen: 'The floating window is not open',
|
|
758
|
+
currentModelLine: 'Current model: {model} · {window} · the in-app pet follows the same settings',
|
|
759
|
+
spanNow: 'currently {px} px',
|
|
760
|
+
noScreenSize: 'the host did not report a screen size',
|
|
761
|
+
statusReadFailed: 'Could not read the floating window status; changes are still written to the settings.',
|
|
762
|
+
gazeNotRunning: 'The floating window is not open, so there are no live numbers; the setting is stored and the in-app pet uses it too.',
|
|
763
|
+
gazeNoReport: 'The floating window has not reported its gaze numbers yet; restart it and this line shows the curve and aim it really uses.',
|
|
764
|
+
gazeApplied: 'The renderer is using: curve {curve} · full deflection {spanX}×{spanY} px · current aim {aimX}, {aimY}; read every 2 seconds, so if it moves with the slider the change has reached the renderer',
|
|
765
|
+
setupOpenedTab: 'The guide opened in a new tab.',
|
|
766
|
+
setupFallbackTab: 'The launcher could not open a window, so the guide opened in a new tab instead.',
|
|
767
|
+
setupOpenedWindow: 'The guide opened in a new window.',
|
|
768
|
+
setupNoHost: 'The host does not serve /vrmpet3d/desktop.',
|
|
769
|
+
settingsUnsupported: 'This host does not serve /vrmpet3d/settings: the settings live only in this page own storage.',
|
|
770
|
+
},
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
/** The language this page draws in — resolved once per render pass by `applyLanguage`, read by `t` at call time. */
|
|
774
|
+
var language = 'zh';
|
|
775
|
+
|
|
776
|
+
/** Whether the static markup has been swept once — see `applyLanguage`, which sweeps at boot and on a CHANGE only. */
|
|
777
|
+
var languagePainted = false;
|
|
778
|
+
|
|
779
|
+
/** One entry of `TEXT` in the CURRENT language; the KEY comes back when the table has no entry, so a gap shows. */
|
|
780
|
+
function t(key) {
|
|
781
|
+
return core.textOf(TEXT, language, key);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* `{token}` substitution for the sentences that carry a number or a name inside them.
|
|
786
|
+
*
|
|
787
|
+
* A whole sentence with a placeholder rather than three concatenated fragments, because the fragments do not sit in
|
|
788
|
+
* the same order in the two languages — and `core.applyI18n` cannot do this part: it writes a string verbatim, which
|
|
789
|
+
* is exactly what the marked-up labels want.
|
|
790
|
+
*/
|
|
791
|
+
function fill(template, values) {
|
|
792
|
+
var out = String(template);
|
|
793
|
+
Object.keys(values || {}).forEach(function (key) {
|
|
794
|
+
out = out.split('{' + key + '}').join(String(values[key]));
|
|
795
|
+
});
|
|
796
|
+
return out;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Paint the page in the language this profile resolves to, and say whether it CHANGED.
|
|
801
|
+
*
|
|
802
|
+
* The static markup is one `core.applyI18n` sweep — `data-i18n` writes text, `-html` writes our own sentence with a
|
|
803
|
+
* `<strong>` in it, `-placeholder`/`-aria` write those attributes — and both documents put it on their `<title>` and
|
|
804
|
+
* their nav as well, so nothing here is special-cased.
|
|
805
|
+
*
|
|
806
|
+
* `documentElement.lang` follows the RESOLVED language rather than staying at the markup's `zh`: the pages ship
|
|
807
|
+
* `lang="zh"` as their no-JS fallback, and a page that kept claiming Chinese while drawing English would also feed
|
|
808
|
+
* that lie back through `core.browserLanguageHints`' second hint.
|
|
809
|
+
*
|
|
810
|
+
* The resolved language is stored in `language` (which `t` reads) and compared with the last one, because the
|
|
811
|
+
* snapshot handler polls the host: only a REAL change repaints the built half of the page (`repaintLanguage`).
|
|
812
|
+
*
|
|
813
|
+
* The SWEEP itself is also gated on that comparison, and it has to be: `#status` is a marked node (its placeholder
|
|
814
|
+
* is 「正在读取…」), so sweeping on every poll would rewrite the live status line every few hundred milliseconds and
|
|
815
|
+
* leave the host's own sentence replaced by "reading…" for as long as the poll's fetch took. It runs at boot and on
|
|
816
|
+
* a change — which is exactly what the language needs and nothing more.
|
|
817
|
+
*/
|
|
818
|
+
function applyLanguage() {
|
|
819
|
+
var lang = core.resolveLanguage(preferences.language, core.browserLanguageHints());
|
|
820
|
+
var changed = lang !== language;
|
|
821
|
+
language = lang;
|
|
822
|
+
if (document.documentElement) document.documentElement.lang = lang;
|
|
823
|
+
if (changed || !languagePainted) {
|
|
824
|
+
languagePainted = true;
|
|
825
|
+
core.applyI18n(document, TEXT, lang);
|
|
826
|
+
}
|
|
827
|
+
return changed;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Re-emit everything a language change has to reach that `core.applyI18n` cannot.
|
|
832
|
+
*
|
|
833
|
+
* `renderAll` covers the sections built from `preferences`; the two import lists are drawn from their own cached
|
|
834
|
+
* payloads (re-rendering them from a snapshot would fight a list the user is working in, which is why they are NOT
|
|
835
|
+
* part of the ordinary repaint) and the 碎碎念 rows build their labels once, so those get their own hops here.
|
|
836
|
+
*/
|
|
837
|
+
function repaintLanguage() {
|
|
838
|
+
renderAll();
|
|
839
|
+
relabelSpeechStates();
|
|
840
|
+
renderImported();
|
|
841
|
+
renderImportedMotions();
|
|
842
|
+
}
|
|
843
|
+
|
|
60
844
|
/**
|
|
61
845
|
* The local cache of the settings, under the CURRENT key or one this project used before it was renamed.
|
|
62
846
|
*
|
|
@@ -159,7 +943,7 @@
|
|
|
159
943
|
try {
|
|
160
944
|
globalThis.localStorage.setItem(STORE, JSON.stringify(preferences));
|
|
161
945
|
} catch (error) {
|
|
162
|
-
setStatus('
|
|
946
|
+
setStatus(t('writeFailed') + (error && error.message));
|
|
163
947
|
}
|
|
164
948
|
if (!hostSync) return;
|
|
165
949
|
var patch = {};
|
|
@@ -203,7 +987,7 @@
|
|
|
203
987
|
globalThis.setTimeout(function () {
|
|
204
988
|
if (!loading) return;
|
|
205
989
|
setLoading(true);
|
|
206
|
-
setStatus('
|
|
990
|
+
setStatus(t('readTimeout'), 'warn');
|
|
207
991
|
}, 8000);
|
|
208
992
|
|
|
209
993
|
function setStatus(text, kind) {
|
|
@@ -324,7 +1108,7 @@
|
|
|
324
1108
|
.catch(function () {
|
|
325
1109
|
// A failure is an ANSWER: the cover comes off so the reason in `#status` can be read, and nothing spins on.
|
|
326
1110
|
setLoading(true);
|
|
327
|
-
setStatus('
|
|
1111
|
+
setStatus(t('catalogFailed'), 'warn');
|
|
328
1112
|
});
|
|
329
1113
|
}
|
|
330
1114
|
|
|
@@ -359,7 +1143,7 @@
|
|
|
359
1143
|
text.title = title || value;
|
|
360
1144
|
var remove = document.createElement('button');
|
|
361
1145
|
remove.className = 'ghost model-import-remove' + (legacy ? ' legacy' : '');
|
|
362
|
-
remove.textContent = '
|
|
1146
|
+
remove.textContent = t('remove');
|
|
363
1147
|
remove.addEventListener('click', function () { onRemove(); });
|
|
364
1148
|
row.appendChild(key);
|
|
365
1149
|
row.appendChild(text);
|
|
@@ -375,11 +1159,11 @@
|
|
|
375
1159
|
imported.models.forEach(function (entry) {
|
|
376
1160
|
// The SOURCE is what the user recognises ("the one I picked out of my Downloads"), while the copy's own path
|
|
377
1161
|
// is what gets removed — so the row shows the former and acts on the latter.
|
|
378
|
-
list.appendChild(importedRow('
|
|
1162
|
+
list.appendChild(importedRow(t('importTag'), entry.name, entry.source || entry.file,
|
|
379
1163
|
function () { removeImported(entry.file); }));
|
|
380
1164
|
});
|
|
381
1165
|
imported.dirs.forEach(function (dir) {
|
|
382
|
-
list.appendChild(importedRow('
|
|
1166
|
+
list.appendChild(importedRow(t('legacyDir'), dir, dir, function () { removeImportedDir(dir); }, true));
|
|
383
1167
|
});
|
|
384
1168
|
if (!note) return;
|
|
385
1169
|
if (imported.error) {
|
|
@@ -390,10 +1174,12 @@
|
|
|
390
1174
|
note.className = 'hint';
|
|
391
1175
|
var total = imported.models.length + imported.dirs.length;
|
|
392
1176
|
note.textContent = total
|
|
393
|
-
? '
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
1177
|
+
? fill(t('importedModelsNote'), {
|
|
1178
|
+
models: imported.models.length,
|
|
1179
|
+
legacy: imported.dirs.length ? fill(t('legacyDirsClause'), { dirs: imported.dirs.length }) : '',
|
|
1180
|
+
total: imported.modelCount,
|
|
1181
|
+
})
|
|
1182
|
+
: t('onlyBundledModels');
|
|
397
1183
|
}
|
|
398
1184
|
|
|
399
1185
|
function loadImported() {
|
|
@@ -404,12 +1190,12 @@
|
|
|
404
1190
|
imported.dirs = body && Array.isArray(body.importedDirs) ? body.importedDirs : [];
|
|
405
1191
|
imported.modelCount = body && Number.isFinite(Number(body.modelCount)) ? Number(body.modelCount) : 0;
|
|
406
1192
|
imported.catalogFile = body && body.catalogFile ? body.catalogFile : null;
|
|
407
|
-
imported.error = body ? null : '
|
|
1193
|
+
imported.error = body ? null : t('modelsRouteMissing');
|
|
408
1194
|
renderImported();
|
|
409
1195
|
return imported;
|
|
410
1196
|
})
|
|
411
1197
|
.catch(function () {
|
|
412
|
-
imported.error = '
|
|
1198
|
+
imported.error = t('readModelsFailed');
|
|
413
1199
|
renderImported();
|
|
414
1200
|
return imported;
|
|
415
1201
|
});
|
|
@@ -429,7 +1215,7 @@
|
|
|
429
1215
|
}).then(function (response) {
|
|
430
1216
|
if (!response.ok) {
|
|
431
1217
|
return response.text().then(function (text) {
|
|
432
|
-
imported.error = text || ('
|
|
1218
|
+
imported.error = text || fill(t('importFailedHttp'), { status: response.status });
|
|
433
1219
|
renderImported();
|
|
434
1220
|
return null;
|
|
435
1221
|
});
|
|
@@ -439,14 +1225,14 @@
|
|
|
439
1225
|
imported.dirs = Array.isArray(payload.importedDirs) ? payload.importedDirs : imported.dirs;
|
|
440
1226
|
imported.modelCount = Number(payload.modelCount) || 0;
|
|
441
1227
|
imported.error = payload.persisted === false
|
|
442
|
-
? '
|
|
1228
|
+
? fill(t('persistFailedClause'), { reason: payload.persistError || t('unknownReason') })
|
|
443
1229
|
: null;
|
|
444
1230
|
renderImported();
|
|
445
1231
|
// The catalog is rebuilt on every request, so the model picker picks the new models up immediately.
|
|
446
1232
|
return loadCatalog();
|
|
447
1233
|
});
|
|
448
1234
|
}).catch(function () {
|
|
449
|
-
imported.error = '
|
|
1235
|
+
imported.error = t('writeModelsFailed');
|
|
450
1236
|
renderImported();
|
|
451
1237
|
return null;
|
|
452
1238
|
});
|
|
@@ -483,11 +1269,11 @@
|
|
|
483
1269
|
if (!picker) {
|
|
484
1270
|
// No launcher bridge: the typed path IS the import UI, so reveal it rather than failing silently.
|
|
485
1271
|
if (manualRow) manualRow.hidden = false;
|
|
486
|
-
imported.error = '
|
|
1272
|
+
imported.error = t('noFilePicker');
|
|
487
1273
|
renderImported();
|
|
488
1274
|
return;
|
|
489
1275
|
}
|
|
490
|
-
Promise.resolve(picker()).then(function (chosen) { return addImported(chosen); });
|
|
1276
|
+
Promise.resolve(picker(language)).then(function (chosen) { return addImported(chosen); });
|
|
491
1277
|
});
|
|
492
1278
|
}
|
|
493
1279
|
if (manual && manualRow) {
|
|
@@ -531,11 +1317,11 @@
|
|
|
531
1317
|
// was picked from is not recorded, so moving it later cannot break the pack — the operator's reason for the
|
|
532
1318
|
// change: 「万一文件夹移位了,也会出bug」. A directory an older build imported is still listed, and removable.
|
|
533
1319
|
importedMotions.imports.forEach(function (entry) {
|
|
534
|
-
list.appendChild(importedRow('
|
|
1320
|
+
list.appendChild(importedRow(t('importTag'), fill(t('motionImportedValue'), { name: entry.name, clips: entry.clips }), entry.source || entry.dir,
|
|
535
1321
|
function () { removeImportedMotion(entry.dir); }));
|
|
536
1322
|
});
|
|
537
1323
|
importedMotions.dirs.forEach(function (dir) {
|
|
538
|
-
list.appendChild(importedRow('
|
|
1324
|
+
list.appendChild(importedRow(t('legacyDir'), dir, dir, function () { removeLegacyMotionDir(dir); }, true));
|
|
539
1325
|
});
|
|
540
1326
|
if (!note) return;
|
|
541
1327
|
if (importedMotions.error) {
|
|
@@ -546,10 +1332,12 @@
|
|
|
546
1332
|
note.className = 'hint';
|
|
547
1333
|
var total = importedMotions.imports.length + importedMotions.dirs.length;
|
|
548
1334
|
note.textContent = total
|
|
549
|
-
? '
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
1335
|
+
? fill(t('importedMotionsNote'), {
|
|
1336
|
+
motions: importedMotions.imports.length,
|
|
1337
|
+
legacy: importedMotions.dirs.length ? fill(t('legacyDirsClause'), { dirs: importedMotions.dirs.length }) : '',
|
|
1338
|
+
clips: importedMotions.clips,
|
|
1339
|
+
})
|
|
1340
|
+
: t('onlyBundledMotions');
|
|
553
1341
|
}
|
|
554
1342
|
|
|
555
1343
|
function loadImportedMotions() {
|
|
@@ -560,12 +1348,12 @@
|
|
|
560
1348
|
importedMotions.dirs = body && Array.isArray(body.importedDirs) ? body.importedDirs : [];
|
|
561
1349
|
importedMotions.clips = body && Array.isArray(body.motions) ? body.motions.length : 0;
|
|
562
1350
|
importedMotions.catalogFile = body && body.catalogFile ? body.catalogFile : null;
|
|
563
|
-
importedMotions.error = body ? null : '
|
|
1351
|
+
importedMotions.error = body ? null : t('motionsRouteMissing');
|
|
564
1352
|
renderImportedMotions();
|
|
565
1353
|
return importedMotions;
|
|
566
1354
|
})
|
|
567
1355
|
.catch(function () {
|
|
568
|
-
importedMotions.error = '
|
|
1356
|
+
importedMotions.error = t('readMotionsFailed');
|
|
569
1357
|
renderImportedMotions();
|
|
570
1358
|
return importedMotions;
|
|
571
1359
|
});
|
|
@@ -581,7 +1369,7 @@
|
|
|
581
1369
|
}).then(function (response) {
|
|
582
1370
|
if (!response.ok) {
|
|
583
1371
|
return response.text().then(function (text) {
|
|
584
|
-
importedMotions.error = text || ('
|
|
1372
|
+
importedMotions.error = text || fill(t('importFailedHttp'), { status: response.status });
|
|
585
1373
|
renderImportedMotions();
|
|
586
1374
|
return null;
|
|
587
1375
|
});
|
|
@@ -601,13 +1389,13 @@
|
|
|
601
1389
|
importedMotions.clips = motions.length;
|
|
602
1390
|
}
|
|
603
1391
|
importedMotions.error = payload.persisted === false
|
|
604
|
-
? '
|
|
1392
|
+
? fill(t('persistFailedClause'), { reason: payload.persistError || t('unknownReason') })
|
|
605
1393
|
: null;
|
|
606
1394
|
renderImportedMotions();
|
|
607
1395
|
return motions ? null : loadMotions();
|
|
608
1396
|
});
|
|
609
1397
|
}).catch(function () {
|
|
610
|
-
importedMotions.error = '
|
|
1398
|
+
importedMotions.error = t('writeMotionsFailed');
|
|
611
1399
|
renderImportedMotions();
|
|
612
1400
|
return null;
|
|
613
1401
|
});
|
|
@@ -643,11 +1431,11 @@
|
|
|
643
1431
|
var picker = bridge && typeof bridge.pickMotionDir === 'function' ? bridge.pickMotionDir : null;
|
|
644
1432
|
if (!picker) {
|
|
645
1433
|
if (manualRow) manualRow.hidden = false;
|
|
646
|
-
importedMotions.error = '
|
|
1434
|
+
importedMotions.error = t('noFolderPicker');
|
|
647
1435
|
renderImportedMotions();
|
|
648
1436
|
return;
|
|
649
1437
|
}
|
|
650
|
-
Promise.resolve(picker()).then(function (chosen) { return addImportedMotion(chosen); });
|
|
1438
|
+
Promise.resolve(picker(language)).then(function (chosen) { return addImportedMotion(chosen); });
|
|
651
1439
|
});
|
|
652
1440
|
}
|
|
653
1441
|
if (manual && manualRow) {
|
|
@@ -740,28 +1528,30 @@
|
|
|
740
1528
|
settle();
|
|
741
1529
|
// Same rule as the catalog's failure: the cover lifts so the reason is readable rather than covered.
|
|
742
1530
|
setLoading(true);
|
|
743
|
-
setStatus('
|
|
1531
|
+
setStatus(t('infoFailed') + (error && error.message), 'warn');
|
|
744
1532
|
return null;
|
|
745
1533
|
});
|
|
746
1534
|
modelInfoInFlight = { id: id, promise: promise };
|
|
747
1535
|
return promise;
|
|
748
1536
|
}
|
|
749
1537
|
|
|
1538
|
+
/** The model file's own licence block as one line: the LABELS are ours and follow `t`, the facts are the file's. */
|
|
750
1539
|
function describeLicence(meta) {
|
|
751
|
-
if (!meta) return '
|
|
1540
|
+
if (!meta) return t('noLicence');
|
|
752
1541
|
var parts = [];
|
|
753
|
-
if (meta.name || meta.title) parts.push('
|
|
754
|
-
if (meta.authors) parts.push('
|
|
755
|
-
if (meta.author) parts.push('
|
|
756
|
-
if (meta.avatarPermission) parts.push('
|
|
757
|
-
if (meta.modification) parts.push('
|
|
758
|
-
if (meta.commercialUsage) parts.push('
|
|
759
|
-
if (meta.creditNotation) parts.push('
|
|
760
|
-
if (meta.licenseUrl) parts.push('
|
|
761
|
-
return parts.length ? parts.join(' · ') : '
|
|
1542
|
+
if (meta.name || meta.title) parts.push(t('licenceName') + (meta.name || meta.title));
|
|
1543
|
+
if (meta.authors) parts.push(t('licenceAuthor') + (meta.authors || []).join('、'));
|
|
1544
|
+
if (meta.author) parts.push(t('licenceAuthor') + meta.author);
|
|
1545
|
+
if (meta.avatarPermission) parts.push(t('licencePermission') + meta.avatarPermission);
|
|
1546
|
+
if (meta.modification) parts.push(t('licenceModification') + meta.modification);
|
|
1547
|
+
if (meta.commercialUsage) parts.push(t('licenceCommercial') + meta.commercialUsage);
|
|
1548
|
+
if (meta.creditNotation) parts.push(t('licenceCredit') + meta.creditNotation);
|
|
1549
|
+
if (meta.licenseUrl) parts.push(t('licenceLicence') + meta.licenseUrl);
|
|
1550
|
+
return parts.length ? parts.join(' · ') : t('noLicence');
|
|
762
1551
|
}
|
|
763
1552
|
|
|
764
1553
|
function renderModel() {
|
|
1554
|
+
renderToon();
|
|
765
1555
|
var spec = element('model-spec');
|
|
766
1556
|
var meta = element('model-meta');
|
|
767
1557
|
if (!spec || !meta) return;
|
|
@@ -773,25 +1563,86 @@
|
|
|
773
1563
|
* avatar was removed (2026-10-10) it was describing a model that cannot be shown. This says the true thing
|
|
774
1564
|
* instead: pick one, or the pet has nothing to draw.
|
|
775
1565
|
*/
|
|
776
|
-
spec.textContent = '
|
|
1566
|
+
spec.textContent = t('noModel');
|
|
777
1567
|
spec.className = 'pill off';
|
|
778
|
-
meta.textContent = '
|
|
1568
|
+
meta.textContent = t('noModelChosen');
|
|
779
1569
|
return;
|
|
780
1570
|
}
|
|
781
|
-
spec.textContent = modelInfo.supported ? (modelInfo.vrmSpec || 'VRM') : '
|
|
1571
|
+
spec.textContent = modelInfo.supported ? (modelInfo.vrmSpec || 'VRM') : t('notVrm');
|
|
782
1572
|
spec.className = 'pill ' + (modelInfo.supported ? 'on' : 'off');
|
|
783
1573
|
var counts = modelInfo.counts || {};
|
|
784
1574
|
var summary = [];
|
|
785
1575
|
if (modelInfo.generator) summary.push(modelInfo.generator);
|
|
786
|
-
summary.push((counts.meshes || 0) + '
|
|
787
|
-
if (typeof modelInfo.humanoid === 'object' && modelInfo.humanoid) summary.push(modelInfo.humanoid.count + '
|
|
788
|
-
if (modelInfo.lookAt && modelInfo.lookAt.type) summary.push('
|
|
1576
|
+
summary.push((counts.meshes || 0) + t('meshUnit') + (counts.materials || 0) + t('materialUnit') + (counts.images || 0) + t('imageUnit'));
|
|
1577
|
+
if (typeof modelInfo.humanoid === 'object' && modelInfo.humanoid) summary.push(modelInfo.humanoid.count + t('humanoidUnit'));
|
|
1578
|
+
if (modelInfo.lookAt && modelInfo.lookAt.type) summary.push(t('lookAtUnit') + modelInfo.lookAt.type);
|
|
789
1579
|
meta.textContent = describeLicence(modelInfo.meta) + ' — ' + summary.join(' · ');
|
|
790
1580
|
}
|
|
791
1581
|
|
|
792
1582
|
// ---------------------------------------------------------------------------
|
|
793
1583
|
// Expressions
|
|
794
1584
|
// ---------------------------------------------------------------------------
|
|
1585
|
+
function renderToon() {
|
|
1586
|
+
var card = element('toon-card');
|
|
1587
|
+
if (!card) return;
|
|
1588
|
+
var id = modelIdInUse();
|
|
1589
|
+
var info = modelInfo && modelInfoId === id && modelInfo.toon;
|
|
1590
|
+
var materials = info && info.materials || [];
|
|
1591
|
+
var supported = Boolean(id && materials.length);
|
|
1592
|
+
var prefs = (preferences.toon || {})[id] || {};
|
|
1593
|
+
var mode = element('toon-mode');
|
|
1594
|
+
var state = element('toon-state');
|
|
1595
|
+
state.textContent = !info ? (id ? t('toonLoading') : t('noModelChosen')) : !supported ? t('toonUnsupported')
|
|
1596
|
+
: t('toonStatus').replace('{n}', materials.length).replace('{total}', info.total)
|
|
1597
|
+
.replace('{state}', t(prefs.enabled === false ? 'toonDisabled' : 'toonEnabled'));
|
|
1598
|
+
mode.value = prefs.enabled === undefined ? 'original' : prefs.enabled ? 'on' : 'off';
|
|
1599
|
+
mode.disabled = !supported;
|
|
1600
|
+
function save(key, value) {
|
|
1601
|
+
if (id !== modelIdInUse()) return;
|
|
1602
|
+
var all = Object.assign({}, preferences.toon || {});
|
|
1603
|
+
var next = Object.assign({}, all[id] || {});
|
|
1604
|
+
if (value === undefined) delete next[key]; else next[key] = value;
|
|
1605
|
+
if (Object.keys(next).length) all[id] = next; else delete all[id];
|
|
1606
|
+
preferences.toon = core.cleanPreferences({ toon: all }).toon;
|
|
1607
|
+
persist();
|
|
1608
|
+
renderToon();
|
|
1609
|
+
}
|
|
1610
|
+
mode.onchange = function () { save('enabled', mode.value === 'original' ? undefined : mode.value === 'on'); };
|
|
1611
|
+
var reset = element('toon-reset');
|
|
1612
|
+
reset.disabled = !id || !Object.keys(prefs).length;
|
|
1613
|
+
reset.onclick = function () {
|
|
1614
|
+
var all = Object.assign({}, preferences.toon || {});
|
|
1615
|
+
delete all[id]; preferences.toon = all; persist(); renderToon();
|
|
1616
|
+
};
|
|
1617
|
+
var controls = element('toon-controls');
|
|
1618
|
+
controls.textContent = '';
|
|
1619
|
+
[
|
|
1620
|
+
['shadingToonyFactor', 'toonToony', 0, 1, 0.01],
|
|
1621
|
+
['shadingShiftFactor', 'toonShift', -1, 1, 0.01],
|
|
1622
|
+
['shadeStrength', 'toonShade', 0, 1, 0.05],
|
|
1623
|
+
['outlineScale', 'toonOutline', 0, 3, 0.05],
|
|
1624
|
+
].forEach(function (spec) {
|
|
1625
|
+
var row = document.createElement('div'); row.className = 'row';
|
|
1626
|
+
var label = document.createElement('label'); label.className = 'k'; label.htmlFor = 'toon-' + spec[0]; label.textContent = t(spec[1]);
|
|
1627
|
+
var input = document.createElement('input'); input.type = 'number'; input.id = label.htmlFor;
|
|
1628
|
+
input.min = spec[2]; input.max = spec[3]; input.step = spec[4];
|
|
1629
|
+
input.value = prefs[spec[0]] === undefined ? '' : prefs[spec[0]];
|
|
1630
|
+
var original = materials.map(function (m) { return m[spec[0]]; });
|
|
1631
|
+
input.placeholder = spec[0] === 'shadeStrength' || spec[0] === 'outlineScale' ? '1'
|
|
1632
|
+
: original.length && original.every(function (v) { return v === original[0]; }) ? String(Number(original[0].toFixed(2))) : t('toonMixed');
|
|
1633
|
+
var noOutline = spec[0] === 'outlineScale' && !materials.some(function (m) { return m.outline; });
|
|
1634
|
+
input.disabled = !supported || prefs.enabled === false || noOutline;
|
|
1635
|
+
input.onchange = function () {
|
|
1636
|
+
if (input.value !== '' && !Number.isFinite(Number(input.value))) return;
|
|
1637
|
+
save(spec[0], input.value === '' ? undefined : Number(input.value));
|
|
1638
|
+
};
|
|
1639
|
+
row.appendChild(label); row.appendChild(input);
|
|
1640
|
+
var note = document.createElement('span'); note.className = 'note';
|
|
1641
|
+
note.textContent = noOutline ? t('toonNoOutline') : spec[2] + ' … ' + spec[3]; row.appendChild(note);
|
|
1642
|
+
controls.appendChild(row);
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
|
|
795
1646
|
function expressionRows() {
|
|
796
1647
|
if (!modelInfo || !Array.isArray(modelInfo.expressions)) return [];
|
|
797
1648
|
return modelInfo.expressions.slice().sort(function (left, right) {
|
|
@@ -815,8 +1666,15 @@
|
|
|
815
1666
|
*/
|
|
816
1667
|
|
|
817
1668
|
|
|
818
|
-
/**
|
|
819
|
-
|
|
1669
|
+
/**
|
|
1670
|
+
* How a trigger row is titled. The KEYS below are UI words read through `t` at draw time; the emotion names in the
|
|
1671
|
+
* same table come from the model and are never translated.
|
|
1672
|
+
*/
|
|
1673
|
+
var TRIGGER_KEYS = { idle: 'triggerIdle', drag: 'triggerDrag', click: 'triggerClick', hold: 'triggerHold' };
|
|
1674
|
+
|
|
1675
|
+
function triggerLabel(trigger) {
|
|
1676
|
+
return t(TRIGGER_KEYS[trigger] || trigger);
|
|
1677
|
+
}
|
|
820
1678
|
|
|
821
1679
|
/**
|
|
822
1680
|
* The body parts a press can land on, as the settings page says them, for the DEFAULT regions.
|
|
@@ -891,7 +1749,7 @@
|
|
|
891
1749
|
core.EMOTION_TRIGGERS.forEach(function (trigger) {
|
|
892
1750
|
var tr = document.createElement('tr');
|
|
893
1751
|
var title = document.createElement('td');
|
|
894
|
-
title.textContent =
|
|
1752
|
+
title.textContent = triggerLabel(trigger);
|
|
895
1753
|
tr.appendChild(title);
|
|
896
1754
|
appendEmotionCells(tr, body, names, {
|
|
897
1755
|
key: trigger,
|
|
@@ -983,7 +1841,7 @@
|
|
|
983
1841
|
var on = document.createElement('input');
|
|
984
1842
|
on.type = 'checkbox';
|
|
985
1843
|
on.checked = row.enabled !== false;
|
|
986
|
-
on.title = '
|
|
1844
|
+
on.title = t('triggerToggleTitle');
|
|
987
1845
|
on.addEventListener('change', function () {
|
|
988
1846
|
spec.write({ enabled: on.checked });
|
|
989
1847
|
persist();
|
|
@@ -994,7 +1852,7 @@
|
|
|
994
1852
|
var listCell = document.createElement('td');
|
|
995
1853
|
if (!names.length) {
|
|
996
1854
|
listCell.className = 'note';
|
|
997
|
-
listCell.textContent = '
|
|
1855
|
+
listCell.textContent = t('noExpressions');
|
|
998
1856
|
}
|
|
999
1857
|
/**
|
|
1000
1858
|
* The expressions are a DROPDOWN multi-select, not a pile of checkboxes.
|
|
@@ -1022,12 +1880,12 @@
|
|
|
1022
1880
|
* the settings cannot represent.
|
|
1023
1881
|
*/
|
|
1024
1882
|
var summary = function () {
|
|
1025
|
-
return row.emotions.length ? row.emotions.join('、') : '
|
|
1883
|
+
return row.emotions.length ? row.emotions.join('、') : t('none');
|
|
1026
1884
|
};
|
|
1027
1885
|
trigger.textContent = summary();
|
|
1028
1886
|
trigger.title = row.emotions.length
|
|
1029
|
-
? '
|
|
1030
|
-
: '
|
|
1887
|
+
? fill(t('selectedExpressions'), { n: row.emotions.length })
|
|
1888
|
+
: t('noneRowTitle');
|
|
1031
1889
|
var menu = null;
|
|
1032
1890
|
var buildMenu = function () {
|
|
1033
1891
|
var panel = document.createElement('div');
|
|
@@ -1037,7 +1895,7 @@
|
|
|
1037
1895
|
var noneBox = document.createElement('input');
|
|
1038
1896
|
noneBox.type = 'checkbox';
|
|
1039
1897
|
noneBox.checked = row.emotions.length === 0;
|
|
1040
|
-
noneBox.title = '
|
|
1898
|
+
noneBox.title = t('noneBoxTitle');
|
|
1041
1899
|
noneBox.addEventListener('change', function () {
|
|
1042
1900
|
if (!noneBox.checked) { noneBox.checked = true; return; }
|
|
1043
1901
|
spec.write({ emotions: [] });
|
|
@@ -1045,7 +1903,7 @@
|
|
|
1045
1903
|
refresh();
|
|
1046
1904
|
});
|
|
1047
1905
|
none.appendChild(noneBox);
|
|
1048
|
-
none.appendChild(document.createTextNode('
|
|
1906
|
+
none.appendChild(document.createTextNode(t('none')));
|
|
1049
1907
|
panel.appendChild(none);
|
|
1050
1908
|
names.forEach(function (name) {
|
|
1051
1909
|
var label = document.createElement('label');
|
|
@@ -1121,10 +1979,10 @@
|
|
|
1121
1979
|
hold.max = '30';
|
|
1122
1980
|
hold.step = '0.5';
|
|
1123
1981
|
hold.value = String(row.holdSeconds);
|
|
1124
|
-
hold.title = '
|
|
1982
|
+
hold.title = t('holdSecondsTitle');
|
|
1125
1983
|
var unit = document.createElement('span');
|
|
1126
1984
|
unit.className = 'note';
|
|
1127
|
-
unit.textContent = '
|
|
1985
|
+
unit.textContent = ' ' + t('secondsUnit') + (row.holdSeconds === 0 ? t('heldSuffix') : '');
|
|
1128
1986
|
hold.addEventListener('change', function () {
|
|
1129
1987
|
spec.write({ holdSeconds: hold.value });
|
|
1130
1988
|
persist();
|
|
@@ -1132,7 +1990,7 @@
|
|
|
1132
1990
|
// same reason the expression menu updates a label instead of calling back into the renderer.
|
|
1133
1991
|
var live = spec.read ? spec.read() : row;
|
|
1134
1992
|
if (live) row = live;
|
|
1135
|
-
unit.textContent = '
|
|
1993
|
+
unit.textContent = ' ' + t('secondsUnit') + (row.holdSeconds === 0 ? t('heldSuffix') : '');
|
|
1136
1994
|
});
|
|
1137
1995
|
holdCell.appendChild(hold);
|
|
1138
1996
|
holdCell.appendChild(unit);
|
|
@@ -1166,7 +2024,7 @@
|
|
|
1166
2024
|
label.className = 'part-label';
|
|
1167
2025
|
label.value = item.label;
|
|
1168
2026
|
label.maxLength = core.PARTS_LIMITS.maxLabelLength;
|
|
1169
|
-
label.title = '
|
|
2027
|
+
label.title = t('partLabelTitle');
|
|
1170
2028
|
label.addEventListener('change', function () {
|
|
1171
2029
|
core.setPartItem(preferences, item.id, { label: label.value });
|
|
1172
2030
|
persist();
|
|
@@ -1176,7 +2034,7 @@
|
|
|
1176
2034
|
if (item.region) {
|
|
1177
2035
|
var regionNote = document.createElement('span');
|
|
1178
2036
|
regionNote.className = 'note';
|
|
1179
|
-
regionNote.textContent = '
|
|
2037
|
+
regionNote.textContent = ' ' + t('regionDefault') + ' ' + (PART_LABELS[item.region] || item.region);
|
|
1180
2038
|
labelCell.appendChild(regionNote);
|
|
1181
2039
|
}
|
|
1182
2040
|
tr.appendChild(labelCell);
|
|
@@ -1200,8 +2058,8 @@
|
|
|
1200
2058
|
var remove = document.createElement('button');
|
|
1201
2059
|
remove.type = 'button';
|
|
1202
2060
|
remove.className = 'part-remove';
|
|
1203
|
-
remove.textContent = '
|
|
1204
|
-
remove.title = '
|
|
2061
|
+
remove.textContent = t('partRemove');
|
|
2062
|
+
remove.title = t('partRemoveTitle');
|
|
1205
2063
|
remove.addEventListener('click', function () {
|
|
1206
2064
|
core.removePartItem(preferences, item.id);
|
|
1207
2065
|
persist();
|
|
@@ -1236,8 +2094,8 @@
|
|
|
1236
2094
|
*/
|
|
1237
2095
|
var partSource = core.cleanSpeech(preferences.speech).source;
|
|
1238
2096
|
lines.placeholder = partSource === 'ai'
|
|
1239
|
-
? '
|
|
1240
|
-
: '
|
|
2097
|
+
? t('partLinesAiPlaceholder')
|
|
2098
|
+
: t('partLinesPlaceholder');
|
|
1241
2099
|
lines.value = item.speech.lines.join('\n');
|
|
1242
2100
|
lines.addEventListener('change', function () {
|
|
1243
2101
|
core.setPartItem(preferences, item.id, { speech: { lines: lines.value } });
|
|
@@ -1260,7 +2118,7 @@
|
|
|
1260
2118
|
* ONE hint per source, for every part. Both shipped strings still ACT when a box is empty, and `builtinSpeech`
|
|
1261
2119
|
* in the published state still carries them — what is gone is only the announcement in the field.
|
|
1262
2120
|
*/
|
|
1263
|
-
prompt.placeholder = '
|
|
2121
|
+
prompt.placeholder = t('partPromptPlaceholder');
|
|
1264
2122
|
prompt.value = item.speech.prompt;
|
|
1265
2123
|
prompt.maxLength = core.SPEECH_LIMITS ? core.SPEECH_LIMITS.maxPrompt : 300;
|
|
1266
2124
|
prompt.addEventListener('change', function () {
|
|
@@ -1291,10 +2149,8 @@
|
|
|
1291
2149
|
var mode = document.createElement('button');
|
|
1292
2150
|
mode.type = 'button';
|
|
1293
2151
|
mode.className = 'chip-mode';
|
|
1294
|
-
mode.textContent = entry.exact ? '
|
|
1295
|
-
mode.title = entry.exact
|
|
1296
|
-
? '只匹配这个名字本身;点一下变成前缀匹配'
|
|
1297
|
-
: '匹配这个名字和它下面的一切(LiuHai.L.002 也算);点一下变成只匹配它本身';
|
|
2152
|
+
mode.textContent = entry.exact ? t('matchExact') : t('matchPrefixWord');
|
|
2153
|
+
mode.title = entry.exact ? t('matchExactTitle') : t('matchPrefixTitle');
|
|
1298
2154
|
mode.addEventListener('click', function () {
|
|
1299
2155
|
var next = item.match.map(function (candidate) {
|
|
1300
2156
|
return candidate.name === entry.name && candidate.exact === entry.exact
|
|
@@ -1309,7 +2165,7 @@
|
|
|
1309
2165
|
drop.type = 'button';
|
|
1310
2166
|
drop.className = 'chip-drop';
|
|
1311
2167
|
drop.textContent = '×';
|
|
1312
|
-
drop.title = '
|
|
2168
|
+
drop.title = t('chipDropTitle');
|
|
1313
2169
|
drop.addEventListener('click', function () {
|
|
1314
2170
|
var next = item.match.filter(function (candidate) {
|
|
1315
2171
|
return !(candidate.name === entry.name && candidate.exact === entry.exact);
|
|
@@ -1325,7 +2181,7 @@
|
|
|
1325
2181
|
var region = core.structureRegionOf(entry.name);
|
|
1326
2182
|
var hint = document.createElement('span');
|
|
1327
2183
|
hint.className = 'note';
|
|
1328
|
-
hint.textContent = ' ' + (region ? (PART_LABELS[region] || region) : '
|
|
2184
|
+
hint.textContent = ' ' + (region ? (PART_LABELS[region] || region) : t('unrecognised'));
|
|
1329
2185
|
chip.appendChild(hint);
|
|
1330
2186
|
cell.appendChild(chip);
|
|
1331
2187
|
});
|
|
@@ -1333,8 +2189,8 @@
|
|
|
1333
2189
|
var add = document.createElement('input');
|
|
1334
2190
|
add.type = 'text';
|
|
1335
2191
|
add.className = 'part-add-structure';
|
|
1336
|
-
add.placeholder = '
|
|
1337
|
-
add.title = '
|
|
2192
|
+
add.placeholder = t('addStructurePlaceholder');
|
|
2193
|
+
add.title = t('addStructureTitle');
|
|
1338
2194
|
var commit = function () {
|
|
1339
2195
|
var name = add.value.trim();
|
|
1340
2196
|
if (!name) return;
|
|
@@ -1358,8 +2214,8 @@
|
|
|
1358
2214
|
var browse = document.createElement('button');
|
|
1359
2215
|
browse.type = 'button';
|
|
1360
2216
|
browse.className = 'part-browse';
|
|
1361
|
-
browse.textContent = '
|
|
1362
|
-
browse.title = '
|
|
2217
|
+
browse.textContent = t('browse');
|
|
2218
|
+
browse.title = t('browseTitle');
|
|
1363
2219
|
browse.addEventListener('click', function () { openStructurePicker(item.id); });
|
|
1364
2220
|
cell.appendChild(browse);
|
|
1365
2221
|
}
|
|
@@ -1433,8 +2289,8 @@
|
|
|
1433
2289
|
name.textContent = entry.name;
|
|
1434
2290
|
var meta = document.createElement('span');
|
|
1435
2291
|
meta.className = 'note';
|
|
1436
|
-
meta.textContent = (region ? (PART_LABELS[region] || region) : '
|
|
1437
|
-
+ (owned ? '
|
|
2292
|
+
meta.textContent = (region ? (PART_LABELS[region] || region) : t('unrecognised')) + ' · ' + entry.kind
|
|
2293
|
+
+ (owned ? t('addedSuffix') : '');
|
|
1438
2294
|
row.appendChild(name);
|
|
1439
2295
|
row.appendChild(meta);
|
|
1440
2296
|
row.addEventListener('click', function () {
|
|
@@ -1446,7 +2302,7 @@
|
|
|
1446
2302
|
});
|
|
1447
2303
|
list.appendChild(row);
|
|
1448
2304
|
});
|
|
1449
|
-
if (count) count.textContent = structures.length
|
|
2305
|
+
if (count) count.textContent = fill(t('structuresCount'), { n: structures.length, total: modelStructures().length });
|
|
1450
2306
|
}
|
|
1451
2307
|
|
|
1452
2308
|
function bindBehaviourControls() {
|
|
@@ -1507,7 +2363,7 @@
|
|
|
1507
2363
|
if (element('part-max')) element('part-max').textContent = String(core.PARTS_LIMITS.maxItems);
|
|
1508
2364
|
if (add) {
|
|
1509
2365
|
add.addEventListener('click', function () {
|
|
1510
|
-
core.addPartItem(preferences, '
|
|
2366
|
+
core.addPartItem(preferences, t('newPart'));
|
|
1511
2367
|
persist();
|
|
1512
2368
|
renderParts();
|
|
1513
2369
|
});
|
|
@@ -1533,7 +2389,8 @@
|
|
|
1533
2389
|
renderParts();
|
|
1534
2390
|
}
|
|
1535
2391
|
|
|
1536
|
-
|
|
2392
|
+
/** The three spring fields' names, resolved through `t` every time a chain is drawn. */
|
|
2393
|
+
var SPRING_LABEL_KEYS = { stiffness: 'springStiffness', dragForce: 'springDrag', gravityPower: 'springGravity' };
|
|
1537
2394
|
|
|
1538
2395
|
/**
|
|
1539
2396
|
* One control block per spring chain of the configured model.
|
|
@@ -1590,9 +2447,9 @@
|
|
|
1590
2447
|
// where it can be changed.
|
|
1591
2448
|
var radius = Number(entry.radius);
|
|
1592
2449
|
text.textContent = [
|
|
1593
|
-
entry.groupName || ('
|
|
1594
|
-
entry.bone || '
|
|
1595
|
-
entry.kind === 'sphere' ? '
|
|
2450
|
+
entry.groupName || (t('colliderGroupPrefix') + entry.group),
|
|
2451
|
+
entry.bone || t('unnamedBone'),
|
|
2452
|
+
entry.kind === 'sphere' ? t('shapeSphere') : (entry.kind || t('shapeWord')),
|
|
1596
2453
|
].filter(Boolean).join(' · ');
|
|
1597
2454
|
/**
|
|
1598
2455
|
* …and the SIZE box (operator, 2026-10-14: 「增加修改弹簧骨碰撞体球尺寸的方法」).
|
|
@@ -1610,7 +2467,7 @@
|
|
|
1610
2467
|
size.max = String(range.max);
|
|
1611
2468
|
size.step = '0.005';
|
|
1612
2469
|
size.style.width = '86px';
|
|
1613
|
-
size.title = '
|
|
2470
|
+
size.title = t('colliderRadiusTitle');
|
|
1614
2471
|
var overrides = preferences.springColliderRadius && typeof preferences.springColliderRadius === 'object'
|
|
1615
2472
|
? preferences.springColliderRadius
|
|
1616
2473
|
: {};
|
|
@@ -1669,13 +2526,13 @@
|
|
|
1669
2526
|
|
|
1670
2527
|
if (!chains.length) {
|
|
1671
2528
|
if (note) {
|
|
1672
|
-
note.textContent = modelInfo ? '
|
|
2529
|
+
note.textContent = modelInfo ? t('noSprings') : '';
|
|
1673
2530
|
}
|
|
1674
2531
|
return;
|
|
1675
2532
|
}
|
|
1676
2533
|
if (note) {
|
|
1677
2534
|
var joints = chains.reduce(function (total, chain) { return total + (chain.bones || 0); }, 0);
|
|
1678
|
-
note.textContent = '
|
|
2535
|
+
note.textContent = fill(t('chainsTotal'), { chains: chains.length, joints: joints });
|
|
1679
2536
|
}
|
|
1680
2537
|
|
|
1681
2538
|
var modelId = modelIdInUse();
|
|
@@ -1700,7 +2557,7 @@
|
|
|
1700
2557
|
// the page state a live check reads.
|
|
1701
2558
|
details.setAttribute('data-grouped', String(chains.length));
|
|
1702
2559
|
var summary = document.createElement('summary');
|
|
1703
|
-
summary.textContent = '
|
|
2560
|
+
summary.textContent = fill(t('expandChains'), { n: chains.length });
|
|
1704
2561
|
details.appendChild(summary);
|
|
1705
2562
|
list.appendChild(details);
|
|
1706
2563
|
host = details;
|
|
@@ -1729,12 +2586,12 @@
|
|
|
1729
2586
|
kind: modelInfo && modelInfo.vrmSpec && String(modelInfo.vrmSpec).indexOf('1.0') === 0 ? 'vrm1' : 'vrm0',
|
|
1730
2587
|
}, Number.isFinite(chain.index) ? chain.index : chainIndex);
|
|
1731
2588
|
name.textContent = chain.comment || chain.name
|
|
1732
|
-
|| (chain.first ? chain.first + '
|
|
2589
|
+
|| (chain.first ? chain.first + t('unnamedChainSuffix') : t('unnamedChainPrefix') + (chainIndex + 1));
|
|
1733
2590
|
var meta = document.createElement('div');
|
|
1734
2591
|
meta.className = 'note';
|
|
1735
2592
|
meta.textContent = (chain.bones || 0) + ' joint'
|
|
1736
|
-
+ (chain.entries && chain.entries !== chain.bones ? '
|
|
1737
|
-
+ (chain.colliderGroups ? '
|
|
2593
|
+
+ (chain.entries && chain.entries !== chain.bones ? fill(t('entriesListed'), { n: chain.entries }) : '')
|
|
2594
|
+
+ (chain.colliderGroups ? fill(t('colliderGroupsNote'), { n: chain.colliderGroups }) : '');
|
|
1738
2595
|
head.appendChild(name);
|
|
1739
2596
|
head.appendChild(meta);
|
|
1740
2597
|
row.appendChild(head);
|
|
@@ -1743,7 +2600,7 @@
|
|
|
1743
2600
|
var cell = document.createElement('div');
|
|
1744
2601
|
cell.className = 'field';
|
|
1745
2602
|
var label = document.createElement('label');
|
|
1746
|
-
label.textContent =
|
|
2603
|
+
label.textContent = t(SPRING_LABEL_KEYS[field] || field);
|
|
1747
2604
|
var range = document.createElement('input');
|
|
1748
2605
|
range.type = 'range';
|
|
1749
2606
|
range.min = String(core.SPRING_LIMITS[field].min);
|
|
@@ -1772,8 +2629,8 @@
|
|
|
1772
2629
|
: (Number.isFinite(shipped) ? shipped : authored);
|
|
1773
2630
|
range.value = String(current);
|
|
1774
2631
|
number.value = String(Number(current).toFixed(2));
|
|
1775
|
-
number.title = '
|
|
1776
|
-
+ (Number.isFinite(shipped) ? '
|
|
2632
|
+
number.title = t('modelValuePrefix') + (Number.isFinite(authored) ? Number(authored).toFixed(3) : '?')
|
|
2633
|
+
+ (Number.isFinite(shipped) ? fill(t('packDefaultPrefix'), { n: Number(shipped).toFixed(3) }) : '');
|
|
1777
2634
|
|
|
1778
2635
|
var write = function (value) {
|
|
1779
2636
|
var stored = core.setSpringOverride(preferences, modelId, chainKey, field, value);
|
|
@@ -1781,28 +2638,28 @@
|
|
|
1781
2638
|
var next = stored === null ? (Number.isFinite(shipped) ? shipped : authored) : stored;
|
|
1782
2639
|
range.value = String(next);
|
|
1783
2640
|
number.value = String(Number(next).toFixed(2));
|
|
1784
|
-
cell.setAttribute('data-state', stored === null ? '
|
|
2641
|
+
cell.setAttribute('data-state', stored === null ? t('stateDefault') : t('stateChanged'));
|
|
1785
2642
|
};
|
|
1786
2643
|
range.addEventListener('input', function () { write(Number(range.value)); });
|
|
1787
2644
|
number.addEventListener('change', function () { write(Number(number.value)); });
|
|
1788
2645
|
|
|
1789
2646
|
var reset = document.createElement('button');
|
|
1790
2647
|
reset.className = 'ghost';
|
|
1791
|
-
reset.textContent = '
|
|
2648
|
+
reset.textContent = t('stateDefault');
|
|
1792
2649
|
reset.addEventListener('click', function () { write(null); });
|
|
1793
2650
|
|
|
1794
2651
|
cell.appendChild(label);
|
|
1795
2652
|
cell.appendChild(range);
|
|
1796
2653
|
cell.appendChild(number);
|
|
1797
2654
|
cell.appendChild(reset);
|
|
1798
|
-
cell.setAttribute('data-state', override && override[field] !== undefined ? '
|
|
2655
|
+
cell.setAttribute('data-state', override && override[field] !== undefined ? t('stateChanged') : t('stateDefault'));
|
|
1799
2656
|
row.appendChild(cell);
|
|
1800
2657
|
});
|
|
1801
2658
|
|
|
1802
2659
|
var actions = document.createElement('div');
|
|
1803
2660
|
var resetChain = document.createElement('button');
|
|
1804
2661
|
resetChain.className = 'ghost';
|
|
1805
|
-
resetChain.textContent = '
|
|
2662
|
+
resetChain.textContent = t('resetChain');
|
|
1806
2663
|
resetChain.addEventListener('click', function () {
|
|
1807
2664
|
core.clearSpringOverrides(preferences, modelId, chainKey);
|
|
1808
2665
|
persist();
|
|
@@ -1997,7 +2854,7 @@
|
|
|
1997
2854
|
var size = Number(entry.bytes);
|
|
1998
2855
|
var head = String(entry.name || entry.id) + ' (' + String(entry.format || '').toUpperCase();
|
|
1999
2856
|
var tail = size > 0 ? ', ' + Math.max(1, Math.round(size / 1024)) + ' KB' : '';
|
|
2000
|
-
return head + (entry.compressed ? '
|
|
2857
|
+
return head + (entry.compressed ? t('compressedTag') : '') + tail + ')';
|
|
2001
2858
|
}
|
|
2002
2859
|
|
|
2003
2860
|
/** The 循环/单次 marker for one clip IN ONE STATE, showing the mode in force rather than the file's word. */
|
|
@@ -2005,7 +2862,7 @@
|
|
|
2005
2862
|
var mode = document.createElement('span');
|
|
2006
2863
|
mode.className = 'mode';
|
|
2007
2864
|
mode.textContent = core.motionLoopForState(preferences, state, entry.id, entry.loop, motionClipDefaults) === false
|
|
2008
|
-
? '
|
|
2865
|
+
? t('once') : t('loop');
|
|
2009
2866
|
return mode;
|
|
2010
2867
|
}
|
|
2011
2868
|
|
|
@@ -2019,8 +2876,8 @@
|
|
|
2019
2876
|
function dancingOnceMarker() {
|
|
2020
2877
|
var mode = document.createElement('span');
|
|
2021
2878
|
mode.className = 'mode';
|
|
2022
|
-
mode.textContent = '
|
|
2023
|
-
mode.title = '
|
|
2879
|
+
mode.textContent = t('once');
|
|
2880
|
+
mode.title = t('dancingOnceTitle');
|
|
2024
2881
|
return mode;
|
|
2025
2882
|
}
|
|
2026
2883
|
|
|
@@ -2055,14 +2912,12 @@
|
|
|
2055
2912
|
function clipFaceSelect(state, entry, field) {
|
|
2056
2913
|
var select = document.createElement('select');
|
|
2057
2914
|
select.className = 'clip-face clip-' + field;
|
|
2058
|
-
select.title = field === 'expression'
|
|
2059
|
-
? '这支动作播放时显示的表情(覆盖情绪表的挑选)'
|
|
2060
|
-
: '这支动作播放时的口型(覆盖表情自带的口型)';
|
|
2915
|
+
select.title = field === 'expression' ? t('clipExpressionTitle') : t('clipMouthTitle');
|
|
2061
2916
|
var names = field === 'mouth' ? mouthNames() : expressionRows().map(function (row) { return row.name; });
|
|
2062
2917
|
var followValue = core.MOTION_FACE_FOLLOW || 'follow';
|
|
2063
2918
|
var follow = document.createElement('option');
|
|
2064
2919
|
follow.value = followValue;
|
|
2065
|
-
follow.textContent = field === 'mouth' ? '
|
|
2920
|
+
follow.textContent = field === 'mouth' ? t('followMouth') : t('followExpression');
|
|
2066
2921
|
select.appendChild(follow);
|
|
2067
2922
|
/**
|
|
2068
2923
|
* And 「默认」: no expression (or mouth) at all — the model's own initial face, overriding both the clip's own
|
|
@@ -2071,12 +2926,12 @@
|
|
|
2071
2926
|
*/
|
|
2072
2927
|
var blankFace = document.createElement('option');
|
|
2073
2928
|
blankFace.value = core.MOTION_FACE_DEFAULT || 'none';
|
|
2074
|
-
blankFace.textContent = field === 'mouth' ? '
|
|
2929
|
+
blankFace.textContent = field === 'mouth' ? t('blankMouth') : t('blankExpression');
|
|
2075
2930
|
select.appendChild(blankFace);
|
|
2076
2931
|
names.forEach(function (name) {
|
|
2077
2932
|
var option = document.createElement('option');
|
|
2078
2933
|
option.value = name;
|
|
2079
|
-
option.textContent = (field === 'mouth' ? '
|
|
2934
|
+
option.textContent = (field === 'mouth' ? t('mouthPrefix') : t('expressionPrefix')) + name;
|
|
2080
2935
|
select.appendChild(option);
|
|
2081
2936
|
});
|
|
2082
2937
|
// The RESOLVED answer: the profile's own field, else the template's, else 「跟随」 — which is what the pet is
|
|
@@ -2191,8 +3046,10 @@
|
|
|
2191
3046
|
var storedMotions = effective.stored;
|
|
2192
3047
|
if (count) {
|
|
2193
3048
|
var total = core.motionIdList(mapped).length;
|
|
2194
|
-
count.textContent =
|
|
2195
|
-
|
|
3049
|
+
count.textContent = fill(t('motionCount'), {
|
|
3050
|
+
offered: offered.length,
|
|
3051
|
+
active: total ? fill(t('motionCountActive'), { n: total }) : '',
|
|
3052
|
+
});
|
|
2196
3053
|
}
|
|
2197
3054
|
if (note) {
|
|
2198
3055
|
/**
|
|
@@ -2204,17 +3061,13 @@
|
|
|
2204
3061
|
* directories THEY imported. The count stays separate for the same reason.
|
|
2205
3062
|
*/
|
|
2206
3063
|
var bundled = offered.filter(function (entry) { return entry.source === 'bundled'; }).length;
|
|
2207
|
-
var summary = bundled
|
|
2208
|
-
? '其中 ' + bundled + ' 支随插件分发(assets/motions),不需要导入。'
|
|
2209
|
-
: '';
|
|
3064
|
+
var summary = bundled ? fill(t('bundledNote'), { n: bundled }) : '';
|
|
2210
3065
|
/**
|
|
2211
3066
|
* Compressed clips are counted too, because their size would otherwise read as a lie: a dance clip listed
|
|
2212
3067
|
* as 2.7 MB while it plays as an 8 MB animation looks like a broken number rather than like an archive.
|
|
2213
3068
|
*/
|
|
2214
3069
|
var packed = offered.filter(function (entry) { return entry.compressed === true; }).length;
|
|
2215
|
-
var packing = packed
|
|
2216
|
-
? '其中 ' + packed + ' 支是压缩存放(.br),播放时自动解压到内存,体积按压缩后的文件计。'
|
|
2217
|
-
: '';
|
|
3070
|
+
var packing = packed ? fill(t('packedNote'), { n: packed }) : '';
|
|
2218
3071
|
/**
|
|
2219
3072
|
* All-compressed reads differently from some-compressed.
|
|
2220
3073
|
*
|
|
@@ -2224,9 +3077,9 @@
|
|
|
2224
3077
|
*/
|
|
2225
3078
|
note.textContent = offered.length
|
|
2226
3079
|
? (packed === offered.length && packed > 0
|
|
2227
|
-
? summary + '
|
|
3080
|
+
? summary + fill(t('packedAllNote'), { n: packed })
|
|
2228
3081
|
: summary + packing)
|
|
2229
|
-
: '
|
|
3082
|
+
: t('noMotionFiles');
|
|
2230
3083
|
}
|
|
2231
3084
|
|
|
2232
3085
|
states.forEach(function (state) {
|
|
@@ -2261,8 +3114,8 @@
|
|
|
2261
3114
|
var stateCount = document.createElement('span');
|
|
2262
3115
|
stateCount.className = 'count';
|
|
2263
3116
|
stateCount.textContent = chosen.length
|
|
2264
|
-
? (configured ? '
|
|
2265
|
-
: (configured ? '
|
|
3117
|
+
? fill(t(configured ? 'motionStateCount' : 'motionStateDefaultCount'), { n: chosen.length })
|
|
3118
|
+
: (configured ? t('notPlaying') : t('proceduralIdle'));
|
|
2266
3119
|
summary.appendChild(name);
|
|
2267
3120
|
/**
|
|
2268
3121
|
* The folder this state's clips come from, in the summary line.
|
|
@@ -2276,7 +3129,7 @@
|
|
|
2276
3129
|
// Every folder the state draws from, because a state may name several and the operator edits exactly
|
|
2277
3130
|
// this line to change the list.
|
|
2278
3131
|
folderTag.textContent = folders.map(function (name) { return name + '/'; }).join(' ')
|
|
2279
|
-
+ ' · ' + inFolder.length
|
|
3132
|
+
+ ' · ' + fill(t('folderClips'), { n: inFolder.length });
|
|
2280
3133
|
summary.appendChild(folderTag);
|
|
2281
3134
|
}
|
|
2282
3135
|
summary.appendChild(stateCount);
|
|
@@ -2288,8 +3141,8 @@
|
|
|
2288
3141
|
*
|
|
2289
3142
|
* One row per status state, in the same box set as that state's motion, because the two questions belong to
|
|
2290
3143
|
* the same subject — "what does the pet look like, and what does it say". The operator's five prompts ship as
|
|
2291
|
-
* the defaults (`core.DEFAULT_STATUS_TEXT
|
|
2292
|
-
* clearing a box means this state says NOTHING, which is not the same as leaving it alone.
|
|
3144
|
+
* the defaults (`core.DEFAULT_STATUS_TEXT`, one table per language), so the boxes open already filled and can
|
|
3145
|
+
* simply be edited; clearing a box means this state says NOTHING, which is not the same as leaving it alone.
|
|
2293
3146
|
*
|
|
2294
3147
|
* NOT for the three pointer poses, and that asymmetry is deliberate rather than an oversight: 拖动中 /
|
|
2295
3148
|
* 抛掷中 / 落地中 carry a MOTION row since 2026-10-13 (「缺少拖动时模型动作的设置」), but the prompt above the
|
|
@@ -2314,7 +3167,7 @@
|
|
|
2314
3167
|
var every = document.createElement('div');
|
|
2315
3168
|
every.className = 'every';
|
|
2316
3169
|
var everyLabel = document.createElement('span');
|
|
2317
|
-
everyLabel.textContent = '
|
|
3170
|
+
everyLabel.textContent = t('everyLabel');
|
|
2318
3171
|
var everyMin = document.createElement('input');
|
|
2319
3172
|
everyMin.type = 'number';
|
|
2320
3173
|
everyMin.min = String(core.MOTION_EVERY_LIMITS.minSeconds);
|
|
@@ -2328,7 +3181,7 @@
|
|
|
2328
3181
|
everyMax.max = String(core.MOTION_EVERY_LIMITS.maxSeconds);
|
|
2329
3182
|
everyMax.step = '5';
|
|
2330
3183
|
var everyUnit = document.createElement('span');
|
|
2331
|
-
everyUnit.textContent = '
|
|
3184
|
+
everyUnit.textContent = t('secondsUnit');
|
|
2332
3185
|
var everyNote = document.createElement('span');
|
|
2333
3186
|
everyNote.className = 'note';
|
|
2334
3187
|
var oneShotCount = chosen.filter(function (id) {
|
|
@@ -2336,8 +3189,8 @@
|
|
|
2336
3189
|
return Boolean(entry) && core.motionLoopFor(preferences, id, entry.loop) === false;
|
|
2337
3190
|
}).length;
|
|
2338
3191
|
everyNote.textContent = oneShotCount
|
|
2339
|
-
? '
|
|
2340
|
-
: '
|
|
3192
|
+
? fill(t('oneShotHere'), { n: oneShotCount })
|
|
3193
|
+
: t('noOneShot');
|
|
2341
3194
|
everyMin.value = String(range ? range.min : 0);
|
|
2342
3195
|
everyMax.value = String(range ? range.max : 0);
|
|
2343
3196
|
var writeEvery = function () {
|
|
@@ -2380,8 +3233,9 @@
|
|
|
2380
3233
|
// Only when the folders really hold nothing: with the mapping read correctly this is the one honest
|
|
2381
3234
|
// case left (a state mapped to a folder the pack does not fill, like 工作中's `working/`), and it is
|
|
2382
3235
|
// the line that told the operator the list was empty while the clips were sitting in `idle/`.
|
|
2383
|
-
empty.textContent =
|
|
2384
|
-
+ '
|
|
3236
|
+
empty.textContent = fill(t('folderEmpty'), {
|
|
3237
|
+
folders: folders.map(function (name) { return name + '/'; }).join(' '),
|
|
3238
|
+
});
|
|
2385
3239
|
picks.appendChild(empty);
|
|
2386
3240
|
}
|
|
2387
3241
|
shown.forEach(function (entry) {
|
|
@@ -2431,8 +3285,8 @@
|
|
|
2431
3285
|
if (state !== 'dancing') {
|
|
2432
3286
|
var modeSelect = document.createElement('select');
|
|
2433
3287
|
modeSelect.className = 'clip-mode';
|
|
2434
|
-
modeSelect.title = '
|
|
2435
|
-
[['loop', '
|
|
3288
|
+
modeSelect.title = t('clipModeTitle');
|
|
3289
|
+
[['loop', t('loop')], ['once', t('once')]].forEach(function (pair) {
|
|
2436
3290
|
var option = document.createElement('option');
|
|
2437
3291
|
option.value = pair[0];
|
|
2438
3292
|
option.textContent = pair[1];
|
|
@@ -2465,9 +3319,9 @@
|
|
|
2465
3319
|
var speedLimits = core.MOTION_SPEED_LIMITS || { min: 1, max: 1000, default: 100 };
|
|
2466
3320
|
var speedLabel = document.createElement('label');
|
|
2467
3321
|
speedLabel.className = 'clip-speed';
|
|
2468
|
-
speedLabel.title = '
|
|
3322
|
+
speedLabel.title = t('clipSpeedTitle');
|
|
2469
3323
|
var speedText = document.createElement('span');
|
|
2470
|
-
speedText.textContent = '
|
|
3324
|
+
speedText.textContent = t('speed');
|
|
2471
3325
|
var speedBox = document.createElement('input');
|
|
2472
3326
|
speedBox.type = 'number';
|
|
2473
3327
|
speedBox.className = 'clip-speed-box';
|
|
@@ -2523,7 +3377,7 @@
|
|
|
2523
3377
|
*/
|
|
2524
3378
|
var gazeLabel = document.createElement('label');
|
|
2525
3379
|
gazeLabel.className = 'clip-gaze';
|
|
2526
|
-
gazeLabel.title = '
|
|
3380
|
+
gazeLabel.title = t('clipGazeTitle');
|
|
2527
3381
|
var gazeBox = document.createElement('input');
|
|
2528
3382
|
gazeBox.type = 'checkbox';
|
|
2529
3383
|
gazeBox.className = 'clip-gaze-box';
|
|
@@ -2531,7 +3385,7 @@
|
|
|
2531
3385
|
var gazeEntry = clipFaceEntry(state, entry.id);
|
|
2532
3386
|
gazeBox.checked = !(gazeEntry && gazeEntry.gaze === false);
|
|
2533
3387
|
var gazeText = document.createElement('span');
|
|
2534
|
-
gazeText.textContent = '
|
|
3388
|
+
gazeText.textContent = t('gazeWord');
|
|
2535
3389
|
gazeLabel.appendChild(gazeBox);
|
|
2536
3390
|
gazeLabel.appendChild(gazeText);
|
|
2537
3391
|
gazeBox.addEventListener('change', function () {
|
|
@@ -2594,7 +3448,7 @@
|
|
|
2594
3448
|
box.checked = true;
|
|
2595
3449
|
box.disabled = true;
|
|
2596
3450
|
var text = document.createElement('span');
|
|
2597
|
-
text.textContent = id + '
|
|
3451
|
+
text.textContent = id + t('notInCatalog');
|
|
2598
3452
|
label.appendChild(box);
|
|
2599
3453
|
label.appendChild(text);
|
|
2600
3454
|
picks.appendChild(label);
|
|
@@ -2614,17 +3468,16 @@
|
|
|
2614
3468
|
* counter rather than through a reload.
|
|
2615
3469
|
*/
|
|
2616
3470
|
function buildStatusRow(state) {
|
|
2617
|
-
|
|
2618
|
-
var
|
|
2619
|
-
|
|
2620
|
-
: '';
|
|
3471
|
+
// The shipped default in the RESOLVED language, so an English page opens with an English line in the box.
|
|
3472
|
+
var entry = core.statusEntryFor(preferences, state, language);
|
|
3473
|
+
var fallback = core.defaultStatusText ? core.defaultStatusText(state, language) : '';
|
|
2621
3474
|
var wrap = document.createElement('div');
|
|
2622
3475
|
wrap.className = 'status-line';
|
|
2623
3476
|
wrap.setAttribute('data-state', state);
|
|
2624
3477
|
|
|
2625
3478
|
var label = document.createElement('span');
|
|
2626
3479
|
label.className = 'status-label';
|
|
2627
|
-
label.textContent = '
|
|
3480
|
+
label.textContent = t('statusText');
|
|
2628
3481
|
wrap.appendChild(label);
|
|
2629
3482
|
|
|
2630
3483
|
var input = document.createElement('input');
|
|
@@ -2632,8 +3485,8 @@
|
|
|
2632
3485
|
input.className = 'status-text';
|
|
2633
3486
|
input.maxLength = core.STATUS_LIMITS ? core.STATUS_LIMITS.textLength : 40;
|
|
2634
3487
|
input.value = entry.text;
|
|
2635
|
-
input.placeholder = fallback ? fallback + '
|
|
2636
|
-
input.title = '
|
|
3488
|
+
input.placeholder = fallback ? fallback + t('defaultParen') : t('noTextParen');
|
|
3489
|
+
input.title = t('statusTextTitle');
|
|
2637
3490
|
input.addEventListener('change', function () {
|
|
2638
3491
|
preferences.status = core.setStatus(preferences, state, 'text', input.value);
|
|
2639
3492
|
persist();
|
|
@@ -2644,11 +3497,11 @@
|
|
|
2644
3497
|
|
|
2645
3498
|
var tag = document.createElement('select');
|
|
2646
3499
|
tag.className = 'status-tag';
|
|
2647
|
-
tag.title = '
|
|
3500
|
+
tag.title = t('statusTagTitle');
|
|
2648
3501
|
(core.STATUS_TAGS || ['']).forEach(function (value) {
|
|
2649
3502
|
var option = document.createElement('option');
|
|
2650
3503
|
option.value = value;
|
|
2651
|
-
option.textContent = value || '
|
|
3504
|
+
option.textContent = value || t('noTag');
|
|
2652
3505
|
tag.appendChild(option);
|
|
2653
3506
|
});
|
|
2654
3507
|
tag.value = entry.tag;
|
|
@@ -2662,8 +3515,8 @@
|
|
|
2662
3515
|
|
|
2663
3516
|
var animation = document.createElement('select');
|
|
2664
3517
|
animation.className = 'status-animation';
|
|
2665
|
-
animation.title = '
|
|
2666
|
-
[['none', '
|
|
3518
|
+
animation.title = t('statusAnimationTitle');
|
|
3519
|
+
[['none', t('animStatic')], ['shimmer', t('animShimmer')]].forEach(function (pair) {
|
|
2667
3520
|
var option = document.createElement('option');
|
|
2668
3521
|
option.value = pair[0];
|
|
2669
3522
|
option.textContent = pair[1];
|
|
@@ -2698,10 +3551,8 @@
|
|
|
2698
3551
|
var awaitingAnswer = state === 'waiting';
|
|
2699
3552
|
duration.disabled = awaitingAnswer;
|
|
2700
3553
|
duration.title = awaitingAnswer
|
|
2701
|
-
? '
|
|
2702
|
-
: (state === 'done' || state === 'error'
|
|
2703
|
-
? '至少显示多久;之后一直留到你切回这个会话(默认 4.2 秒是下限)'
|
|
2704
|
-
: '这个状态下文字显示多久;0 表示一直显示到状态结束');
|
|
3554
|
+
? t('waitingDurationTitle')
|
|
3555
|
+
: (state === 'done' || state === 'error' ? t('noticeDurationTitle') : t('normalDurationTitle'));
|
|
2705
3556
|
duration.addEventListener('change', function () {
|
|
2706
3557
|
var seconds = Math.max(0, Number(duration.value) || 0);
|
|
2707
3558
|
preferences.status = core.setStatus(preferences, state, 'seconds', seconds > 0 ? seconds : null);
|
|
@@ -2713,8 +3564,7 @@
|
|
|
2713
3564
|
|
|
2714
3565
|
var unit = document.createElement('span');
|
|
2715
3566
|
unit.className = 'note';
|
|
2716
|
-
unit.textContent = awaitingAnswer ? '
|
|
2717
|
-
: (untimed ? '秒(至少)' : '秒(0 = 始终)');
|
|
3567
|
+
unit.textContent = awaitingAnswer ? t('waitingUnit') : (untimed ? t('untimedUnit') : t('normalUnit'));
|
|
2718
3568
|
wrap.appendChild(unit);
|
|
2719
3569
|
|
|
2720
3570
|
return wrap;
|
|
@@ -2761,7 +3611,7 @@
|
|
|
2761
3611
|
var warn = element('motion-note');
|
|
2762
3612
|
if (warn) {
|
|
2763
3613
|
warn.className = 'hint warn';
|
|
2764
|
-
warn.textContent = '
|
|
3614
|
+
warn.textContent = fill(t('capWarning'), { cap: cap, state: stateName(state), kept: kept });
|
|
2765
3615
|
}
|
|
2766
3616
|
}
|
|
2767
3617
|
persist();
|
|
@@ -2778,13 +3628,14 @@
|
|
|
2778
3628
|
* which is why the mapping exists at all rather than showing the raw id.
|
|
2779
3629
|
*/
|
|
2780
3630
|
function stateName(state) {
|
|
2781
|
-
|
|
2782
|
-
resting: '
|
|
2783
|
-
done: '
|
|
2784
|
-
held: '
|
|
3631
|
+
var key = {
|
|
3632
|
+
resting: 'stateResting', working: 'stateWorking', waiting: 'stateWaiting',
|
|
3633
|
+
done: 'stateDone', error: 'stateError', offline: 'stateOffline',
|
|
3634
|
+
held: 'stateHeld', falling: 'stateFalling', landing: 'stateLanding',
|
|
2785
3635
|
// 跳舞 is the state the USER asks for (the 跳支舞 menu row), not one DSH reports — which is also why it has no status prompt.
|
|
2786
|
-
dancing: '
|
|
2787
|
-
}[state]
|
|
3636
|
+
dancing: 'stateDancing',
|
|
3637
|
+
}[state];
|
|
3638
|
+
return key ? t(key) : state;
|
|
2788
3639
|
}
|
|
2789
3640
|
|
|
2790
3641
|
// ---------------------------------------------------------------------------
|
|
@@ -2809,6 +3660,19 @@
|
|
|
2809
3660
|
var windowSizeReset = element('pref-window-size-reset');
|
|
2810
3661
|
var viewReset = element('pref-view-reset');
|
|
2811
3662
|
var hidden = element('pref-hidden');
|
|
3663
|
+
/**
|
|
3664
|
+
* 界面语言 — the one control that changes the LANGUAGE every other label on the page is written in.
|
|
3665
|
+
*
|
|
3666
|
+
* The stored value is `auto` / `zh` / `en` and never the RESOLVED language (`core.LANGUAGE_SETTINGS`): a profile
|
|
3667
|
+
* that froze today's answer would keep a Chinese page for someone who later switched DSH to English, with no way
|
|
3668
|
+
* to tell that from a deliberate choice.
|
|
3669
|
+
*
|
|
3670
|
+
* The repaint is immediate and local as well as stored, because the patch comes back as a settings snapshot and
|
|
3671
|
+
* `adoptSnapshot` refuses to repaint while a control has focus — and this control HAS focus right after a change.
|
|
3672
|
+
* So the write goes out AND `applyLanguage` / `repaintLanguage` run here; the snapshot path then finds nothing
|
|
3673
|
+
* changed and does nothing.
|
|
3674
|
+
*/
|
|
3675
|
+
var languageSelect = element('pref-language');
|
|
2812
3676
|
/**
|
|
2813
3677
|
* 调试模式 — the one control whose preference is not a boolean but a value of 显示位置.
|
|
2814
3678
|
*
|
|
@@ -2865,6 +3729,18 @@
|
|
|
2865
3729
|
if (physics) physics.addEventListener('change', function () { write('physics', physics.checked); });
|
|
2866
3730
|
if (motion) motion.addEventListener('change', function () { write('motion', motion.checked); });
|
|
2867
3731
|
if (look) look.addEventListener('change', function () { write('lookAtPointer', look.checked); });
|
|
3732
|
+
if (languageSelect) {
|
|
3733
|
+
languageSelect.addEventListener('change', function () {
|
|
3734
|
+
write('language', core.cleanLanguage(languageSelect.value));
|
|
3735
|
+
/**
|
|
3736
|
+
* And the page follows AT ONCE rather than waiting for the snapshot to come back: the change is a label
|
|
3737
|
+
* change, not a control edit, so it is safe to repaint even though this select still has focus. The status
|
|
3738
|
+
* line is not part of `repaintLanguage` (it describes the PET), so it is re-read here too.
|
|
3739
|
+
*/
|
|
3740
|
+
if (applyLanguage()) repaintLanguage();
|
|
3741
|
+
refreshStatus();
|
|
3742
|
+
});
|
|
3743
|
+
}
|
|
2868
3744
|
if (size) {
|
|
2869
3745
|
size.addEventListener('input', function () {
|
|
2870
3746
|
write('size', Number(size.value));
|
|
@@ -2907,6 +3783,7 @@
|
|
|
2907
3783
|
if (motion) motion.checked = preferences.motion !== false;
|
|
2908
3784
|
if (look) look.checked = preferences.lookAtPointer !== false;
|
|
2909
3785
|
if (hidden) hidden.checked = preferences.hidden === true;
|
|
3786
|
+
if (languageSelect) languageSelect.value = core.cleanLanguage(preferences.language);
|
|
2910
3787
|
syncBoth();
|
|
2911
3788
|
syncSleep();
|
|
2912
3789
|
writeSize();
|
|
@@ -2932,6 +3809,14 @@
|
|
|
2932
3809
|
*/
|
|
2933
3810
|
var speechRows = {};
|
|
2934
3811
|
var speechLlm = null;
|
|
3812
|
+
/**
|
|
3813
|
+
* `showSpeech` and the 试一句 picker, kept so a language change can re-label both.
|
|
3814
|
+
*
|
|
3815
|
+
* The rows above are built ONCE (`renderSpeechStates`), so their labels and the picker's options cannot be
|
|
3816
|
+
* repainted by a re-render — see `relabelSpeechStates`, which is the one hop that reaches them.
|
|
3817
|
+
*/
|
|
3818
|
+
var speechShow = null;
|
|
3819
|
+
var speechTestState = null;
|
|
2935
3820
|
/**
|
|
2936
3821
|
* The provider whose MODEL LIST the last `/vrmpet3d/llm` answer carried, and the providers asked about.
|
|
2937
3822
|
*
|
|
@@ -2983,10 +3868,11 @@
|
|
|
2983
3868
|
|
|
2984
3869
|
/** One state's name as this page says it; 空闲 carries the idle remark, which has no state of its own. */
|
|
2985
3870
|
function speechStateName(state) {
|
|
2986
|
-
|
|
2987
|
-
resting: '
|
|
2988
|
-
done: '
|
|
2989
|
-
}[state]
|
|
3871
|
+
var key = {
|
|
3872
|
+
resting: 'speechStateResting', working: 'stateWorking', waiting: 'stateWaiting',
|
|
3873
|
+
done: 'stateDone', error: 'stateError', offline: 'stateOffline',
|
|
3874
|
+
}[state];
|
|
3875
|
+
return key ? t(key) : state;
|
|
2990
3876
|
}
|
|
2991
3877
|
|
|
2992
3878
|
/** Show or hide one of the AI rows; `hidden` alone would lose to `.row{display:flex}`. */
|
|
@@ -3034,19 +3920,13 @@
|
|
|
3034
3920
|
* never silence in any source. Until that day the shipped table was switched off by choosing 自定义/AI (2026-10-11) and
|
|
3035
3921
|
* these labels said 「不说话」 for one of them, which is exactly what a busy model made the whole pet do.
|
|
3036
3922
|
*/
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
custom: '留空则用内置短语',
|
|
3045
|
-
ai: '留空则用内置 AI 提示词生成一句(生成期间先显示内置短语)',
|
|
3046
|
-
};
|
|
3047
|
-
var emptyLinesLabel = function (source) {
|
|
3048
|
-
return EMPTY_LINES_LABEL[source] || EMPTY_LINES_LABEL.builtin;
|
|
3049
|
-
};
|
|
3923
|
+
function emptyLinesLabel(source) {
|
|
3924
|
+
return source === 'ai' ? t('emptyLinesAi') : t('emptyLinesBuiltin');
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3927
|
+
function emptyLinesPlaceholder(source) {
|
|
3928
|
+
return source === 'ai' ? t('emptyLinesPlaceholderAi') : t('emptyLinesPlaceholderBuiltin');
|
|
3929
|
+
}
|
|
3050
3930
|
|
|
3051
3931
|
/**
|
|
3052
3932
|
* Build the per-state boxes ONCE, then write into them.
|
|
@@ -3085,21 +3965,22 @@
|
|
|
3085
3965
|
body.className = 'speech-body';
|
|
3086
3966
|
var linesLabel = document.createElement('span');
|
|
3087
3967
|
linesLabel.className = 'speech-label';
|
|
3088
|
-
linesLabel.textContent = '
|
|
3968
|
+
linesLabel.textContent = t('linesLabel');
|
|
3089
3969
|
var lines = document.createElement('textarea');
|
|
3090
3970
|
lines.className = 'speech-lines';
|
|
3091
3971
|
lines.rows = 3;
|
|
3092
3972
|
lines.spellcheck = false;
|
|
3093
3973
|
// Written per SOURCE in `showSpeech`, which runs on every change and on every settings snapshot.
|
|
3094
|
-
lines.placeholder =
|
|
3974
|
+
lines.placeholder = t('emptyLinesPlaceholderBuiltin');
|
|
3095
3975
|
var promptLabel = document.createElement('span');
|
|
3096
3976
|
promptLabel.className = 'speech-label';
|
|
3097
|
-
promptLabel.textContent = '
|
|
3977
|
+
promptLabel.textContent = t('promptLabel');
|
|
3098
3978
|
var prompt = document.createElement('input');
|
|
3099
3979
|
prompt.type = 'text';
|
|
3100
3980
|
prompt.className = 'speech-prompt';
|
|
3101
3981
|
prompt.spellcheck = false;
|
|
3102
|
-
|
|
3982
|
+
// The instruction itself is core's own shipped prompt (Chinese, and sent to the model); only the fallback is ours.
|
|
3983
|
+
prompt.placeholder = (core.DEFAULT_SPEECH_PROMPTS || {})[state] || t('defaultSpeechPrompt');
|
|
3103
3984
|
body.appendChild(linesLabel);
|
|
3104
3985
|
body.appendChild(lines);
|
|
3105
3986
|
body.appendChild(promptLabel);
|
|
@@ -3107,10 +3988,43 @@
|
|
|
3107
3988
|
details.appendChild(body);
|
|
3108
3989
|
wrap.appendChild(details);
|
|
3109
3990
|
list.appendChild(wrap);
|
|
3110
|
-
speechRows[state] = {
|
|
3991
|
+
speechRows[state] = {
|
|
3992
|
+
details: details, name: name, count: count,
|
|
3993
|
+
linesLabel: linesLabel, promptLabel: promptLabel,
|
|
3994
|
+
lines: lines, prompt: prompt,
|
|
3995
|
+
};
|
|
3111
3996
|
});
|
|
3112
3997
|
}
|
|
3113
3998
|
|
|
3999
|
+
/**
|
|
4000
|
+
* The 碎碎念 rows' own labels after a language change — the ONE section `renderAll` cannot repaint.
|
|
4001
|
+
*
|
|
4002
|
+
* `renderSpeechStates` builds its rows ONCE, and that guard is load-bearing: rebuilding would close the boxes the
|
|
4003
|
+
* user has open and take the caret out of the one being typed in. So its two static labels and its six state names
|
|
4004
|
+
* would keep the old language for the rest of the session. Rather than forcing a rebuild, this hop rewrites the
|
|
4005
|
+
* handful of text nodes the row owns — no box's VALUE is touched — and then asks `showSpeech` to re-derive the
|
|
4006
|
+
* counts and the grey placeholders, which are written per SOURCE and were never cached.
|
|
4007
|
+
*
|
|
4008
|
+
* The 试一句 picker is here for the same reason: its options are built once, in `bindSpeechControls`.
|
|
4009
|
+
*/
|
|
4010
|
+
function relabelSpeechStates() {
|
|
4011
|
+
(core.SPEECH_STATES || []).forEach(function (state) {
|
|
4012
|
+
var row = speechRows[state];
|
|
4013
|
+
if (!row) return;
|
|
4014
|
+
if (row.name) row.name.textContent = speechStateName(state);
|
|
4015
|
+
if (row.linesLabel) row.linesLabel.textContent = t('linesLabel');
|
|
4016
|
+
if (row.promptLabel) row.promptLabel.textContent = t('promptLabel');
|
|
4017
|
+
// The instruction itself is core's shipped prompt (Chinese, and sent to the model); only the fallback is ours.
|
|
4018
|
+
if (row.prompt) row.prompt.placeholder = (core.DEFAULT_SPEECH_PROMPTS || {})[state] || t('defaultSpeechPrompt');
|
|
4019
|
+
});
|
|
4020
|
+
if (speechTestState) {
|
|
4021
|
+
Array.prototype.forEach.call(speechTestState.options, function (option) {
|
|
4022
|
+
option.textContent = speechStateName(option.value);
|
|
4023
|
+
});
|
|
4024
|
+
}
|
|
4025
|
+
if (speechShow) speechShow();
|
|
4026
|
+
}
|
|
4027
|
+
|
|
3114
4028
|
function bindSpeechControls() {
|
|
3115
4029
|
var enabled = element('pref-speech');
|
|
3116
4030
|
var hold = element('pref-speech-hold');
|
|
@@ -3202,7 +4116,7 @@
|
|
|
3202
4116
|
* '用内置短语'了,应该是写'用内置AI生成提示词'").
|
|
3203
4117
|
*/
|
|
3204
4118
|
var emptyLabel = emptyLinesLabel(speech.source);
|
|
3205
|
-
var emptyPlaceholder =
|
|
4119
|
+
var emptyPlaceholder = emptyLinesPlaceholder(speech.source);
|
|
3206
4120
|
(core.SPEECH_STATES || []).forEach(function (state) {
|
|
3207
4121
|
var row = speechRows[state];
|
|
3208
4122
|
if (!row) return;
|
|
@@ -3211,7 +4125,7 @@
|
|
|
3211
4125
|
if (document.activeElement !== row.lines) row.lines.value = lines.join('\n');
|
|
3212
4126
|
if (document.activeElement !== row.prompt) row.prompt.value = speech.ai.prompts[state] || '';
|
|
3213
4127
|
if (row.lines.placeholder !== emptyPlaceholder) row.lines.placeholder = emptyPlaceholder;
|
|
3214
|
-
row.count.textContent = lines.length ? lines.length
|
|
4128
|
+
row.count.textContent = lines.length ? fill(t('lineCount'), { n: lines.length }) : emptyLabel;
|
|
3215
4129
|
});
|
|
3216
4130
|
if (provider) provider.value = speech.ai.provider;
|
|
3217
4131
|
var custom = speech.ai.provider === 'custom';
|
|
@@ -3301,15 +4215,15 @@
|
|
|
3301
4215
|
}
|
|
3302
4216
|
if (llmNote) {
|
|
3303
4217
|
var parts = [dsh.available
|
|
3304
|
-
? '
|
|
3305
|
-
: '
|
|
3306
|
-
if (dsh.available && !(dsh.providers || []).length) parts.push('
|
|
4218
|
+
? fill(t('llmAvailable'), { n: (dsh.providers || []).length })
|
|
4219
|
+
: t('llmUnavailable')];
|
|
4220
|
+
if (dsh.available && !(dsh.providers || []).length) parts.push(t('noProviders'));
|
|
3307
4221
|
if (dsh.available && dsh.provider && !(dsh.models || []).length) {
|
|
3308
|
-
parts.push('
|
|
4222
|
+
parts.push(t('providerNoModels'));
|
|
3309
4223
|
}
|
|
3310
|
-
if (dsh.error) parts.push('
|
|
3311
|
-
parts.push('
|
|
3312
|
-
llmNote.textContent = parts.join('
|
|
4224
|
+
if (dsh.error) parts.push(t('readFailedPrefix') + dsh.error);
|
|
4225
|
+
parts.push(t('customProviderNote'));
|
|
4226
|
+
llmNote.textContent = parts.join(t('noteJoin'));
|
|
3313
4227
|
}
|
|
3314
4228
|
showSpeech();
|
|
3315
4229
|
/**
|
|
@@ -3332,7 +4246,7 @@
|
|
|
3332
4246
|
publish();
|
|
3333
4247
|
})
|
|
3334
4248
|
.catch(function () {
|
|
3335
|
-
if (llmNote) llmNote.textContent = '
|
|
4249
|
+
if (llmNote) llmNote.textContent = t('llmNoAnswer');
|
|
3336
4250
|
});
|
|
3337
4251
|
};
|
|
3338
4252
|
|
|
@@ -3469,7 +4383,7 @@
|
|
|
3469
4383
|
if (testButton) {
|
|
3470
4384
|
testButton.addEventListener('click', function () {
|
|
3471
4385
|
var state = testState && (core.SPEECH_STATES || []).indexOf(testState.value) !== -1 ? testState.value : 'working';
|
|
3472
|
-
showTestResult('
|
|
4386
|
+
showTestResult(t('generating'), false);
|
|
3473
4387
|
// One at a time: the host refuses a second call while the first is running, and that refusal would
|
|
3474
4388
|
// otherwise land on top of the answer the user is waiting for.
|
|
3475
4389
|
testButton.disabled = true;
|
|
@@ -3478,25 +4392,31 @@
|
|
|
3478
4392
|
method: 'POST',
|
|
3479
4393
|
headers: { 'content-type': 'application/json' },
|
|
3480
4394
|
credentials: 'omit',
|
|
3481
|
-
|
|
4395
|
+
// The HOST answers in this language, so the test line reads like the pet the user is configuring.
|
|
4396
|
+
body: JSON.stringify({ state: state, language: language }),
|
|
3482
4397
|
}).then(function (response) { return response.json(); })
|
|
3483
4398
|
.then(function (body) {
|
|
3484
4399
|
settle();
|
|
3485
4400
|
if (body && body.text) {
|
|
3486
|
-
showTestResult('
|
|
4401
|
+
showTestResult(t('quoteOpen') + body.text + t('quoteClose')
|
|
4402
|
+
+ (Number.isFinite(body.ms) ? fill(t('msParen'), { ms: body.ms }) : ''), false);
|
|
3487
4403
|
} else {
|
|
3488
|
-
showTestResult('
|
|
4404
|
+
showTestResult(t('failedPrefix') + ((body && body.error) || t('hostNoAnswer')), true);
|
|
3489
4405
|
}
|
|
3490
4406
|
})
|
|
3491
4407
|
.catch(function (error) {
|
|
3492
4408
|
settle();
|
|
3493
|
-
showTestResult('
|
|
4409
|
+
showTestResult(t('failedPrefix') + (error && error.message), true);
|
|
3494
4410
|
});
|
|
3495
4411
|
});
|
|
3496
4412
|
}
|
|
3497
4413
|
|
|
3498
4414
|
renderSpeechStates();
|
|
3499
4415
|
showSpeech();
|
|
4416
|
+
// The two handles a language change needs: `showSpeech` re-derives the counts and the grey placeholders from
|
|
4417
|
+
// the source, and the 试一句 picker's options are built once and carry state names.
|
|
4418
|
+
speechShow = showSpeech;
|
|
4419
|
+
speechTestState = testState;
|
|
3500
4420
|
loadLlm(core.cleanSpeech(preferences.speech).ai.dshProvider || '');
|
|
3501
4421
|
}
|
|
3502
4422
|
|
|
@@ -3506,16 +4426,17 @@
|
|
|
3506
4426
|
* Both are SETTINGS rather than constants in the renderer (they were `GAZE_CURVE_EDGE_SLOPE` and a fixed
|
|
3507
4427
|
* span), because the right shape depends on the display and on the person: on a wide monitor the default
|
|
3508
4428
|
* span makes the head turn very little, and anyone who wants it livelier lowers the curve's edge slope
|
|
3509
|
-
* instead of reaching for a different pet. `0` in either span field means AUTO — the
|
|
4429
|
+
* instead of reaching for a different pet. `0` in either span field means AUTO — half the screen width —
|
|
3510
4430
|
* and the hint under the row says which number that resolves to for the floating window.
|
|
3511
4431
|
*/
|
|
3512
4432
|
function bindGazeControls() {
|
|
3513
|
-
var
|
|
3514
|
-
var curveValue = element('pref-gaze-curve-value');
|
|
3515
|
-
var curveReset = element('pref-gaze-curve-reset');
|
|
4433
|
+
var curveMode = element('pref-gaze-mode');
|
|
3516
4434
|
var spanX = element('pref-gaze-span-x');
|
|
3517
4435
|
var spanY = element('pref-gaze-span-y');
|
|
3518
4436
|
var spanReset = element('pref-gaze-span-reset');
|
|
4437
|
+
var pitchUp = element('pref-gaze-pitch-up');
|
|
4438
|
+
var pitchDown = element('pref-gaze-pitch-down');
|
|
4439
|
+
var pitchReset = element('pref-gaze-pitch-reset');
|
|
3519
4440
|
var idle = element('pref-gaze-idle');
|
|
3520
4441
|
var idleReset = element('pref-gaze-idle-reset');
|
|
3521
4442
|
// The draught's four numbers live in the same section — one place for everything that answers "how does the
|
|
@@ -3536,9 +4457,20 @@
|
|
|
3536
4457
|
persist();
|
|
3537
4458
|
};
|
|
3538
4459
|
var showCurve = function () {
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
if (
|
|
4460
|
+
if (curveMode) curveMode.value = preferences.gazeCurveMode;
|
|
4461
|
+
var radial = element('gaze-curves-radial');
|
|
4462
|
+
if (radial) radial.hidden = preferences.gazeCurveMode !== 'radial';
|
|
4463
|
+
var spatial = element('gaze-curves-spatial');
|
|
4464
|
+
if (spatial) spatial.hidden = preferences.gazeCurveMode !== 'spatial';
|
|
4465
|
+
var distance = element('pref-gaze-distance');
|
|
4466
|
+
if (distance) distance.value = String(preferences.gazeDistance);
|
|
4467
|
+
[['radial', 'gazeCurveRadial'], ['spatial', 'gazeCurveSpatial']].forEach(function (entry) {
|
|
4468
|
+
var control = element('pref-gaze-curve-' + entry[0]);
|
|
4469
|
+
var value = element('pref-gaze-curve-' + entry[0] + '-value');
|
|
4470
|
+
var current = preferences[entry[1]];
|
|
4471
|
+
if (control) control.value = String(Math.round(current * 100));
|
|
4472
|
+
if (value) value.textContent = current.toFixed(2) + (current >= 0.995 ? t('straightLine') : '');
|
|
4473
|
+
});
|
|
3542
4474
|
};
|
|
3543
4475
|
var showSpans = function () {
|
|
3544
4476
|
if (spanX) spanX.value = String(Math.round(Number(preferences.gazeSpanX) || 0));
|
|
@@ -3560,25 +4492,28 @@
|
|
|
3560
4492
|
var radius = Number(preferences.windRadius);
|
|
3561
4493
|
if (windRadiusValue) {
|
|
3562
4494
|
windRadiusValue.textContent = Math.round((Number.isFinite(radius) ? radius : 0) * 100) + '%'
|
|
3563
|
-
+ (radius <= 0 ? '
|
|
4495
|
+
+ (radius <= 0 ? t('globalRadius') : '');
|
|
3564
4496
|
}
|
|
3565
4497
|
if (windRadius) windRadius.value = String(Math.round((Number.isFinite(radius) ? radius : 0) * 100));
|
|
3566
4498
|
if (windDecay) windDecay.value = String(Number(preferences.windDecay));
|
|
3567
4499
|
if (windSpeed) windSpeed.value = String(Math.round(Number(preferences.windSpeedForMax)));
|
|
3568
4500
|
};
|
|
3569
4501
|
|
|
3570
|
-
if (
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
4502
|
+
if (curveMode) curveMode.onchange = function () { write('gazeCurveMode', curveMode.value === 'radial' ? 'radial' : 'spatial'); showCurve(); };
|
|
4503
|
+
[['radial', 'gazeCurveRadial'], ['spatial', 'gazeCurveSpatial']].forEach(function (entry) {
|
|
4504
|
+
var control = element('pref-gaze-curve-' + entry[0]);
|
|
4505
|
+
var reset = element('pref-gaze-curve-' + entry[0] + '-reset');
|
|
4506
|
+
if (control) control.oninput = function () { write(entry[1], Number(control.value) / 100); showCurve(); };
|
|
4507
|
+
if (reset) reset.onclick = function () { write(entry[1], core.DEFAULTS[entry[1]]); showCurve(); };
|
|
4508
|
+
});
|
|
4509
|
+
var targetDistance = element('pref-gaze-distance');
|
|
4510
|
+
var targetDistanceReset = element('pref-gaze-distance-reset');
|
|
4511
|
+
if (targetDistance) targetDistance.onchange = function () {
|
|
4512
|
+
var value = targetDistance.valueAsNumber;
|
|
4513
|
+
write('gazeDistance', Number.isFinite(value) ? Math.max(0.1, Math.min(5, value)) : core.DEFAULTS.gazeDistance);
|
|
4514
|
+
showCurve();
|
|
4515
|
+
};
|
|
4516
|
+
if (targetDistanceReset) targetDistanceReset.onclick = function () { write('gazeDistance', core.DEFAULTS.gazeDistance); showCurve(); };
|
|
3582
4517
|
if (spanX) {
|
|
3583
4518
|
spanX.addEventListener('change', function () {
|
|
3584
4519
|
write('gazeSpanX', Math.max(0, Number(spanX.value) || 0));
|
|
@@ -3665,6 +4600,24 @@
|
|
|
3665
4600
|
});
|
|
3666
4601
|
}
|
|
3667
4602
|
showCurve();
|
|
4603
|
+
function showPitch() {
|
|
4604
|
+
if (pitchUp) pitchUp.value = String(Math.round(preferences.gazePitchUp * 10) / 10);
|
|
4605
|
+
if (pitchDown) pitchDown.value = String(Math.round(preferences.gazePitchDown * 10) / 10);
|
|
4606
|
+
}
|
|
4607
|
+
[[pitchUp, 'gazePitchUp'], [pitchDown, 'gazePitchDown']].forEach(function (entry) {
|
|
4608
|
+
if (!entry[0]) return;
|
|
4609
|
+
entry[0].onchange = function () {
|
|
4610
|
+
var value = entry[0].valueAsNumber;
|
|
4611
|
+
write(entry[1], Number.isFinite(value) ? Math.max(0, Math.min(60, value)) : core.DEFAULTS[entry[1]]);
|
|
4612
|
+
showPitch();
|
|
4613
|
+
};
|
|
4614
|
+
});
|
|
4615
|
+
function setPitch(up, down) {
|
|
4616
|
+
preferences.gazePitchUp = up; preferences.gazePitchDown = down;
|
|
4617
|
+
persist(); showPitch();
|
|
4618
|
+
}
|
|
4619
|
+
if (pitchReset) pitchReset.onclick = function () { setPitch(core.DEFAULTS.gazePitchUp, core.DEFAULTS.gazePitchDown); };
|
|
4620
|
+
showPitch();
|
|
3668
4621
|
showSpans();
|
|
3669
4622
|
showIdle();
|
|
3670
4623
|
showWind();
|
|
@@ -3682,7 +4635,7 @@
|
|
|
3682
4635
|
.then(function (response) { return response.ok ? response.json() : null; })
|
|
3683
4636
|
.then(function (status) {
|
|
3684
4637
|
if (!status) {
|
|
3685
|
-
setStatus('
|
|
4638
|
+
setStatus(t('floatingUnavailable'));
|
|
3686
4639
|
return null;
|
|
3687
4640
|
}
|
|
3688
4641
|
var page = status.page || {};
|
|
@@ -3690,25 +4643,28 @@
|
|
|
3690
4643
|
// Say what each surface is showing, because that is what a spring override is keyed by: a user
|
|
3691
4644
|
// configuring a model nobody is showing would otherwise see sliders that do nothing.
|
|
3692
4645
|
var parts = [];
|
|
3693
|
-
parts.push('
|
|
3694
|
-
parts.push('
|
|
4646
|
+
parts.push(fill(t('statusModel'), { model: modelIdInUse() }));
|
|
4647
|
+
parts.push(fill(t('statusWindow'), { state: status.running ? t('windowRunning') : t('windowOff') }));
|
|
3695
4648
|
setStatus(parts.join(' · '));
|
|
3696
4649
|
var shown = status.running
|
|
3697
|
-
?
|
|
3698
|
-
|
|
3699
|
-
|
|
4650
|
+
? (page.ready
|
|
4651
|
+
? fill(t('windowRenderedReady'), { model: status.modelId || '?' })
|
|
4652
|
+
: t('windowRenderedNotReady'))
|
|
4653
|
+
: t('windowNotOpen');
|
|
4654
|
+
setStatus(fill(t('currentModelLine'), { model: modelIdInUse(), window: shown }));
|
|
3700
4655
|
// What "自动" resolves to, for the surface the user is most likely looking at. The host reports the
|
|
3701
4656
|
// work area, and the floating pet's horizontal full deflection IS that width.
|
|
3702
4657
|
var auto = element('pref-gaze-span-auto');
|
|
3703
4658
|
if (auto) {
|
|
3704
|
-
var work = status.
|
|
3705
|
-
|
|
4659
|
+
var work = status.page && status.page.gaze && Number.isFinite(status.page.gaze.autoSpan) ? Math.round(status.page.gaze.autoSpan) :
|
|
4660
|
+
status.display && status.display.workArea ? Math.round(status.display.workArea.width / 2) : 0;
|
|
4661
|
+
auto.textContent = work > 0 ? fill(t('spanNow'), { px: work }) : t('noScreenSize');
|
|
3706
4662
|
}
|
|
3707
4663
|
showAppliedGaze(status);
|
|
3708
4664
|
return status;
|
|
3709
4665
|
})
|
|
3710
4666
|
.catch(function () {
|
|
3711
|
-
setStatus('
|
|
4667
|
+
setStatus(t('statusReadFailed'), 'warn');
|
|
3712
4668
|
return null;
|
|
3713
4669
|
});
|
|
3714
4670
|
}
|
|
@@ -3732,6 +4688,8 @@
|
|
|
3732
4688
|
} else {
|
|
3733
4689
|
live.appliedGaze = {
|
|
3734
4690
|
curve: Number(gaze.curve),
|
|
4691
|
+
curveY: Number.isFinite(gaze.curveY) ? gaze.curveY : Number(gaze.curve),
|
|
4692
|
+
mode: gaze.mode === 'radial' ? 'radial' : 'spatial',
|
|
3735
4693
|
spanX: Math.round(Number(gaze.spanX) || 0),
|
|
3736
4694
|
spanY: Math.round(Number(gaze.spanY) || 0),
|
|
3737
4695
|
aim: { x: Number((gaze.aim && gaze.aim.x) || 0), y: Number((gaze.aim && gaze.aim.y) || 0) },
|
|
@@ -3739,19 +4697,24 @@
|
|
|
3739
4697
|
}
|
|
3740
4698
|
if (!host) return;
|
|
3741
4699
|
if (!status || !status.running) {
|
|
3742
|
-
host.textContent = '
|
|
4700
|
+
host.textContent = t('gazeNotRunning');
|
|
3743
4701
|
return;
|
|
3744
4702
|
}
|
|
3745
4703
|
if (!live.appliedGaze) {
|
|
3746
4704
|
// A window from before this diagnostic existed: say so rather than showing a stale number.
|
|
3747
|
-
host.textContent = '
|
|
4705
|
+
host.textContent = t('gazeNoReport');
|
|
3748
4706
|
return;
|
|
3749
4707
|
}
|
|
3750
4708
|
var aim = live.appliedGaze;
|
|
3751
|
-
host.textContent = '
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
4709
|
+
host.textContent = fill(t('gazeApplied'), {
|
|
4710
|
+
curve: aim.mode === 'spatial' ? t('gazeSpatial') + ' ' + aim.curve.toFixed(2)
|
|
4711
|
+
: aim.mode === 'radial' ? t('gazeRadial') + ' ' + aim.curve.toFixed(2)
|
|
4712
|
+
: t('gazeAxes') + ' ' + aim.curve.toFixed(2) + ' / ' + aim.curveY.toFixed(2),
|
|
4713
|
+
spanX: aim.spanX,
|
|
4714
|
+
spanY: aim.spanY,
|
|
4715
|
+
aimX: aim.aim.x.toFixed(2),
|
|
4716
|
+
aimY: aim.aim.y.toFixed(2),
|
|
4717
|
+
});
|
|
3755
4718
|
}
|
|
3756
4719
|
|
|
3757
4720
|
function bindModelSelect() {
|
|
@@ -3979,14 +4942,15 @@
|
|
|
3979
4942
|
* says whether the pet would say it right now (operator, 2026-10-12: "如果设置了AI碎碎念,就停止输出内置的碎碎念").
|
|
3980
4943
|
*
|
|
3981
4944
|
* Since 2026-10-14 the table has TWO pools per region (点击 / 长按), so the readout publishes both — `lines`
|
|
3982
|
-
* is the click pool and `holdLines` the long-press one, both in
|
|
3983
|
-
*
|
|
4945
|
+
* is the click pool and `holdLines` the long-press one, both in the RESOLVED language, because a readout of
|
|
4946
|
+
* what a line would say has to be in the language the page is drawing in. It reads them with `tableAllowed`
|
|
4947
|
+
* TRUE for the reason above: a readout is not a source.
|
|
3984
4948
|
*/
|
|
3985
4949
|
builtinSpeech: items.filter(function (item) {
|
|
3986
4950
|
return !item.speech.lines.length && !item.speech.prompt;
|
|
3987
4951
|
}).map(function (item) {
|
|
3988
|
-
var builtinLines = core.partSpeechLines ? core.partSpeechLines(item, true,
|
|
3989
|
-
var builtinHold = core.partSpeechLines ? core.partSpeechLines(item, true,
|
|
4952
|
+
var builtinLines = core.partSpeechLines ? core.partSpeechLines(item, true, language, 'click') : [];
|
|
4953
|
+
var builtinHold = core.partSpeechLines ? core.partSpeechLines(item, true, language, 'hold') : [];
|
|
3990
4954
|
var builtinPrompt = core.partSpeechPrompt ? core.partSpeechPrompt(item) : '';
|
|
3991
4955
|
if (!builtinLines.length && !builtinHold.length && !builtinPrompt) return null;
|
|
3992
4956
|
return {
|
|
@@ -4127,7 +5091,7 @@
|
|
|
4127
5091
|
/**
|
|
4128
5092
|
* What an EMPTY box currently says, and in whose words.
|
|
4129
5093
|
*
|
|
4130
|
-
* Published because it is a LABEL that has to follow the source (`
|
|
5094
|
+
* Published because it is a LABEL that has to follow the source (`emptyLinesLabel`), and a label that drifts
|
|
4131
5095
|
* back to 「用内置短语」 while the source is AI is exactly the confusion the operator reported: "AI碎碎念开启后,
|
|
4132
5096
|
* 面板里的各种状态后面就不应该写'用内置短语'了,应该是写'用内置AI生成提示词'."
|
|
4133
5097
|
*/
|
|
@@ -4216,10 +5180,10 @@
|
|
|
4216
5180
|
* which it is. The resolved spans are what "auto" currently means, so the number is checkable.
|
|
4217
5181
|
*/
|
|
4218
5182
|
gaze: {
|
|
4219
|
-
controls: Boolean(element('pref-gaze-curve') && element('pref-gaze-span-x') && element('pref-gaze-span-y')),
|
|
4220
|
-
curve: Number(preferences.
|
|
4221
|
-
spanX: core.resolveGazeSpan(preferences.gazeSpanX, 0),
|
|
4222
|
-
spanY: core.resolveGazeSpan(preferences.gazeSpanY, 0),
|
|
5183
|
+
controls: Boolean(element('pref-gaze-mode') && element('pref-gaze-curve-spatial') && element('pref-gaze-curve-radial') && element('pref-gaze-span-x') && element('pref-gaze-span-y')),
|
|
5184
|
+
curve: Number(preferences.gazeCurveMode === 'radial' ? preferences.gazeCurveRadial : preferences.gazeCurveSpatial),
|
|
5185
|
+
spanX: core.resolveGazeSpan(preferences.gazeSpanX, Number(globalThis.screen && globalThis.screen.width) || 0),
|
|
5186
|
+
spanY: core.resolveGazeSpan(preferences.gazeSpanY, Number(globalThis.screen && globalThis.screen.width) || 0),
|
|
4223
5187
|
// The readout of what the RENDERER applies, and the numbers behind it: `applied` is null until the
|
|
4224
5188
|
// first /vrmpet3d/desktop answer lands, which is why a check asserts the element exists rather than a
|
|
4225
5189
|
// value that depends on a running window.
|
|
@@ -4405,14 +5369,14 @@
|
|
|
4405
5369
|
statusTexts: (function () {
|
|
4406
5370
|
var out = {};
|
|
4407
5371
|
(core.STATUS_STATES || []).forEach(function (state) {
|
|
4408
|
-
out[state] = core.statusLineFor(preferences, state);
|
|
5372
|
+
out[state] = core.statusLineFor(preferences, state, language);
|
|
4409
5373
|
});
|
|
4410
5374
|
return out;
|
|
4411
5375
|
})(),
|
|
4412
5376
|
statusAnimations: (function () {
|
|
4413
5377
|
var out = {};
|
|
4414
5378
|
(core.STATUS_STATES || []).forEach(function (state) {
|
|
4415
|
-
out[state] = core.statusEntryFor(preferences, state).animation;
|
|
5379
|
+
out[state] = core.statusEntryFor(preferences, state, language).animation;
|
|
4416
5380
|
});
|
|
4417
5381
|
return out;
|
|
4418
5382
|
})(),
|
|
@@ -4444,6 +5408,16 @@
|
|
|
4444
5408
|
};
|
|
4445
5409
|
}
|
|
4446
5410
|
|
|
5411
|
+
/**
|
|
5412
|
+
* THE LANGUAGE, before anything draws.
|
|
5413
|
+
*
|
|
5414
|
+
* `tagFixedRows` first (the two rows whose attributes cannot live in the markup — see it), then one
|
|
5415
|
+
* `applyLanguage`, because every renderer below reads `t()` and the static labels are written by the sweep in
|
|
5416
|
+
* there. A change made later comes back through the same pair — see the 界面语言 control and `adoptSnapshot`.
|
|
5417
|
+
*/
|
|
5418
|
+
tagFixedRows();
|
|
5419
|
+
applyLanguage();
|
|
5420
|
+
|
|
4447
5421
|
bindBehaviourControls();
|
|
4448
5422
|
bindParts();
|
|
4449
5423
|
bindPreferenceControls();
|
|
@@ -4465,7 +5439,7 @@
|
|
|
4465
5439
|
if (!button) return;
|
|
4466
5440
|
button.addEventListener('click', function () {
|
|
4467
5441
|
var fallback = function (reason) {
|
|
4468
|
-
if (note) note.textContent = reason || '
|
|
5442
|
+
if (note) note.textContent = reason || t('setupOpenedTab');
|
|
4469
5443
|
try { globalThis.open('/vrmpet3d/setup', '_blank'); } catch (error) { /* the user can open it by hand */ }
|
|
4470
5444
|
};
|
|
4471
5445
|
fetch('/vrmpet3d/desktop', {
|
|
@@ -4474,9 +5448,9 @@
|
|
|
4474
5448
|
headers: { 'content-type': 'application/json' },
|
|
4475
5449
|
body: JSON.stringify({ action: 'setup' }),
|
|
4476
5450
|
}).then(function (response) {
|
|
4477
|
-
if (!response.ok) { fallback('
|
|
4478
|
-
if (note) note.textContent = '
|
|
4479
|
-
}).catch(function () { fallback('
|
|
5451
|
+
if (!response.ok) { fallback(t('setupFallbackTab')); return; }
|
|
5452
|
+
if (note) note.textContent = t('setupOpenedWindow');
|
|
5453
|
+
}).catch(function () { fallback(t('setupNoHost')); });
|
|
4480
5454
|
});
|
|
4481
5455
|
})();
|
|
4482
5456
|
bindModelSelect();
|
|
@@ -4570,8 +5544,18 @@
|
|
|
4570
5544
|
pendingLlmChoice = null;
|
|
4571
5545
|
writeLlmChoice(held);
|
|
4572
5546
|
}
|
|
4573
|
-
|
|
4574
|
-
|
|
5547
|
+
/**
|
|
5548
|
+
* THE LANGUAGE, re-resolved on every snapshot — before either of the two render decisions below.
|
|
5549
|
+
*
|
|
5550
|
+
* It is asked FIRST because a change made in another window (or by this one, which then polls) has to reach the
|
|
5551
|
+
* screen even while a control here has focus: `busyWithAControl` exists to keep a repaint from yanking a slider
|
|
5552
|
+
* out from under a drag, and a label change cannot do that. So a language that really moved repaints through
|
|
5553
|
+
* `repaintLanguage` (which includes the three sections `renderAll` owns plus the ones that live on their own
|
|
5554
|
+
* loaders), and the ordinary poll still does nothing but `renderAll` when nothing changed.
|
|
5555
|
+
*/
|
|
5556
|
+
var languageMoved = applyLanguage();
|
|
5557
|
+
if (busyWithAControl() && !languageMoved) return;
|
|
5558
|
+
if (languageMoved) repaintLanguage(); else renderAll();
|
|
4575
5559
|
refreshStatus();
|
|
4576
5560
|
publish();
|
|
4577
5561
|
}
|
|
@@ -4619,6 +5603,41 @@
|
|
|
4619
5603
|
}
|
|
4620
5604
|
}
|
|
4621
5605
|
|
|
5606
|
+
/**
|
|
5607
|
+
* The two rows whose MARKUP may not carry their `data-i18n` attributes in the file, so they get them here.
|
|
5608
|
+
*
|
|
5609
|
+
* 调试模式 is stripped from release builds by `scripts/make-release-pages.mjs`, which matches the whole row —
|
|
5610
|
+
* comment included — as ONE literal block: a new attribute in the source would make that transform refuse to run.
|
|
5611
|
+
*
|
|
5612
|
+
* 碎碎念's 免责说明 is read by `verify-desktop.mjs` by matching its opening tag with the class and the id in that
|
|
5613
|
+
* exact order and spelling, and the copy is then measured against the guide's longer one — so neither an extra
|
|
5614
|
+
* attribute nor a wrapping `<span>` can go in the file.
|
|
5615
|
+
*
|
|
5616
|
+
* Both are still translated, exactly like every other row: the attributes go on BEFORE the first `applyLanguage`
|
|
5617
|
+
* (see the boot below), and every later pass treats them as markup it always had. On a release build the 调试模式
|
|
5618
|
+
* lookups simply find nothing, which is what the transform intends.
|
|
5619
|
+
*/
|
|
5620
|
+
function tagFixedRows() {
|
|
5621
|
+
var label = document.querySelector('label[for="pref-both"]');
|
|
5622
|
+
if (label) label.setAttribute('data-i18n', 'debugMode');
|
|
5623
|
+
var box = element('pref-both');
|
|
5624
|
+
var wrap = box ? box.parentNode : null;
|
|
5625
|
+
if (wrap) {
|
|
5626
|
+
// The checkbox's own label is a bare text node beside the input; it becomes a span so it can be marked.
|
|
5627
|
+
for (var i = wrap.childNodes.length - 1; i >= 0; i -= 1) {
|
|
5628
|
+
var node = wrap.childNodes[i];
|
|
5629
|
+
if (node.nodeType !== 3 || !node.nodeValue.trim()) continue;
|
|
5630
|
+
var span = document.createElement('span');
|
|
5631
|
+
span.setAttribute('data-i18n', 'debugModeBox');
|
|
5632
|
+
span.textContent = node.nodeValue.trim();
|
|
5633
|
+
wrap.replaceChild(span, node);
|
|
5634
|
+
break;
|
|
5635
|
+
}
|
|
5636
|
+
}
|
|
5637
|
+
var disclaimer = element('speech-disclaimer');
|
|
5638
|
+
if (disclaimer) disclaimer.setAttribute('data-i18n-html', 'speechDisclaimer');
|
|
5639
|
+
}
|
|
5640
|
+
|
|
4622
5641
|
/**
|
|
4623
5642
|
* TWO PAGES, TWO ROUTES — the split, second attempt (operator, 2026-10-11, and again the same day).
|
|
4624
5643
|
*
|
|
@@ -4657,7 +5676,7 @@
|
|
|
4657
5676
|
* control silently do nothing on such a host — a worse failure than the one it guards.
|
|
4658
5677
|
*/
|
|
4659
5678
|
hasSnapshot = true;
|
|
4660
|
-
setStatus('
|
|
5679
|
+
setStatus(t('settingsUnsupported'), 'warn');
|
|
4661
5680
|
},
|
|
4662
5681
|
});
|
|
4663
5682
|
} else {
|