dsh-plugin-vrmpet3d 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/desktop/main.js +45 -3
- package/desktop/preload.js +6 -2
- package/lib/client.core.js +209 -13
- package/lib/client.js +278 -42
- package/lib/config-page.html +71 -45
- package/lib/config-page.js +1120 -229
- package/lib/core.js +209 -13
- package/lib/desktop-page.js +144 -8
- package/lib/global-page.html +140 -99
- package/lib/index.js +150 -33
- package/lib/model-picker.js +139 -49
- package/lib/picker-page.html +34 -8
- package/lib/picker-page.js +192 -11
- package/lib/setup-page.html +68 -49
- package/lib/setup-page.js +395 -42
- 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,753 @@
|
|
|
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
|
+
titleGlobal: 'vrmpet3d · 通用设置',
|
|
95
|
+
headingModel: '模型参数',
|
|
96
|
+
headingGlobal: '通用设置',
|
|
97
|
+
loading: '正在加载设置…',
|
|
98
|
+
reading: '正在读取…',
|
|
99
|
+
navSections: '设置分组',
|
|
100
|
+
// ---- the section names, which are also the nav entries ----
|
|
101
|
+
sectionModel: '模型',
|
|
102
|
+
sectionExpression: '表情',
|
|
103
|
+
sectionParts: '部位',
|
|
104
|
+
sectionSprings: '弹簧骨',
|
|
105
|
+
sectionDisplay: '显示与外观',
|
|
106
|
+
sectionGaze: '视线',
|
|
107
|
+
sectionMouse: '鼠标互动与物理',
|
|
108
|
+
sectionState: '状态与动作',
|
|
109
|
+
sectionSpeech: '碎碎念',
|
|
110
|
+
sectionModelImport: '模型导入',
|
|
111
|
+
sectionMotionImport: '动作导入',
|
|
112
|
+
sectionOther: '其他',
|
|
113
|
+
// ---- words a row repeats ----
|
|
114
|
+
on: '开启',
|
|
115
|
+
reset: '复位',
|
|
116
|
+
close: '关闭',
|
|
117
|
+
auto: '自动',
|
|
118
|
+
secondsUnit: '秒',
|
|
119
|
+
remove: '移除',
|
|
120
|
+
// ---- 界面语言 ----
|
|
121
|
+
language: '界面语言',
|
|
122
|
+
languageAuto: '自动',
|
|
123
|
+
languageZh: '中文',
|
|
124
|
+
languageEn: 'English',
|
|
125
|
+
// ---- 模型参数: 模型 / 表情 ----
|
|
126
|
+
currentModel: '当前模型',
|
|
127
|
+
availableInfo: '可用信息',
|
|
128
|
+
blink: '眨眼',
|
|
129
|
+
interval: '间隔',
|
|
130
|
+
emotion: '情绪',
|
|
131
|
+
idleInterval: '空闲间隔',
|
|
132
|
+
colSituation: '情境',
|
|
133
|
+
colUse: '使用',
|
|
134
|
+
colExpression: '表情',
|
|
135
|
+
colHold: '持续时间',
|
|
136
|
+
// ---- 模型参数: 部位 ----
|
|
137
|
+
partsExpand: '展开部位列表',
|
|
138
|
+
colPart: '部位',
|
|
139
|
+
colStructure: '结构',
|
|
140
|
+
partsAdd: '新增部位',
|
|
141
|
+
partsMaxPrefix: '最多',
|
|
142
|
+
partsMaxSuffix: ' 个',
|
|
143
|
+
filterStructures: '筛选结构名',
|
|
144
|
+
// ---- 模型参数: 弹簧骨 ----
|
|
145
|
+
springMotionWhile: '动作播放时',
|
|
146
|
+
springMotionBox: '头发与裙摆继续摆动',
|
|
147
|
+
springColliders: '弹簧骨碰撞',
|
|
148
|
+
collidersOff: '全部关闭(默认)',
|
|
149
|
+
collidersAll: '全部开启',
|
|
150
|
+
collidersCustom: '自定义',
|
|
151
|
+
collidersDetail: '逐个碰撞体',
|
|
152
|
+
springResetAll: '全部恢复默认',
|
|
153
|
+
// ---- 通用设置: 显示与外观 ----
|
|
154
|
+
petSurface: '桌宠界面',
|
|
155
|
+
surfaceApp: '内置宠物',
|
|
156
|
+
sizeApp: '应用内大小',
|
|
157
|
+
sizeWindow: '悬浮窗大小',
|
|
158
|
+
idleNap: '空闲休眠',
|
|
159
|
+
sleep30: '30 秒',
|
|
160
|
+
sleep120: '2 分钟',
|
|
161
|
+
sleep300: '5 分钟',
|
|
162
|
+
debugMode: '调试模式',
|
|
163
|
+
debugModeBox: '独立桌宠与内置桌宠同时显示',
|
|
164
|
+
view: '视角',
|
|
165
|
+
viewReset: '复位视角',
|
|
166
|
+
pet: '宠物',
|
|
167
|
+
hide: '隐藏',
|
|
168
|
+
// ---- 通用设置: 视线 ----
|
|
169
|
+
lookAtPointer: '看向光标',
|
|
170
|
+
curve: '曲率',
|
|
171
|
+
span: '满偏距离',
|
|
172
|
+
pxHorizontal: 'px 横向',
|
|
173
|
+
pxVertical: 'px 纵向',
|
|
174
|
+
gazeIdle: '静止停看',
|
|
175
|
+
readingWindow: '(正在读取悬浮窗…)',
|
|
176
|
+
// ---- 通用设置: 鼠标互动与物理 ----
|
|
177
|
+
springPhysics: '弹簧骨物理',
|
|
178
|
+
windPower: '划过强度',
|
|
179
|
+
windRadius: '作用范围',
|
|
180
|
+
windDecay: '余韵',
|
|
181
|
+
windSpeed: '满力速度',
|
|
182
|
+
pxPerSecond: 'px/秒',
|
|
183
|
+
// ---- 通用设置: 状态与动作 ----
|
|
184
|
+
playMotion: '播放动画',
|
|
185
|
+
// ---- 通用设置: 碎碎念 (the markup) ----
|
|
186
|
+
speechDisclaimer: '<strong>碎碎念只是玩笑,请勿当真。</strong>它不会对你的项目或文件做任何实际操作,也不会干扰 DSH 的运行;低配机器上 3D 渲染与弹簧骨物理可能轻微影响性能,关掉桌宠或调小尺寸即可缓解。',
|
|
187
|
+
speechEnable: '开启',
|
|
188
|
+
speechTalk: '说话',
|
|
189
|
+
speechHold: '每句显示',
|
|
190
|
+
speechGap: '最短间隔',
|
|
191
|
+
speechIdleInterval: '空闲嘀咕间隔',
|
|
192
|
+
speechWorkInterval: '工作嘀咕间隔',
|
|
193
|
+
speechSource: '台词来源',
|
|
194
|
+
sourceBuiltin: '内置短语',
|
|
195
|
+
sourceCustom: '自定义短语',
|
|
196
|
+
sourceAi: 'AI 生成',
|
|
197
|
+
speechAiFrom: 'AI 来源',
|
|
198
|
+
providerDsh: 'DSH 的 Provider',
|
|
199
|
+
providerCustom: '自定义 Provider',
|
|
200
|
+
speechModel: '模型',
|
|
201
|
+
speechKeyClear: '清除',
|
|
202
|
+
persona: '人格',
|
|
203
|
+
temperature: '温度',
|
|
204
|
+
timeout: '超时',
|
|
205
|
+
speechTest: '试一句',
|
|
206
|
+
generate: '生成',
|
|
207
|
+
// ---- 通用设置: 导入 ----
|
|
208
|
+
importModel: '导入模型',
|
|
209
|
+
pickModelFile: '选择模型文件…',
|
|
210
|
+
manualPath: '手动输入路径',
|
|
211
|
+
filePath: '文件路径',
|
|
212
|
+
import: '导入',
|
|
213
|
+
importFolder: '导入目录',
|
|
214
|
+
pickMotionDir: '选择文件夹…',
|
|
215
|
+
path: '路径',
|
|
216
|
+
add: '添加',
|
|
217
|
+
// ---- 通用设置: 其他 ----
|
|
218
|
+
setupSection: '初次安装引导',
|
|
219
|
+
setupRerun: '重新运行引导',
|
|
220
|
+
// ---- the script's own words ----
|
|
221
|
+
writeFailed: '写入设置失败:',
|
|
222
|
+
readTimeout: '读取设置超时:宿主没有在 8 秒内回应(其余功能仍可手动重试)。',
|
|
223
|
+
catalogFailed: '未能读取模型列表:宿主没有提供 /vrmpet3d/catalog',
|
|
224
|
+
importTag: '已导入',
|
|
225
|
+
legacyDir: '旧版目录',
|
|
226
|
+
importedModelsNote: '已导入 {models} 个模型文件{legacy},当前模型列表里有 {total} 个模型。',
|
|
227
|
+
legacyDirsClause: '、{dirs} 个旧版目录',
|
|
228
|
+
onlyBundledModels: '当前模型列表里只有插件自带的模型。',
|
|
229
|
+
modelsRouteMissing: '宿主没有提供 /vrmpet3d/models,模型导入在这个版本里不可用。',
|
|
230
|
+
readModelsFailed: '读取已导入模型失败:宿主没有提供 /vrmpet3d/models。',
|
|
231
|
+
importFailedHttp: '导入失败(HTTP {status})',
|
|
232
|
+
persistFailedClause: '导入已生效,但没能写入清单文件({reason}),重启后会丢失。',
|
|
233
|
+
unknownReason: '未知原因',
|
|
234
|
+
writeModelsFailed: '写入失败:宿主没有提供 /vrmpet3d/models。',
|
|
235
|
+
noFilePicker: '这个窗口没有文件选择器(不是在 DSH 桌面版里打开的),请在下面直接输入模型文件的路径。',
|
|
236
|
+
motionImportedValue: '{name}({clips} 支)',
|
|
237
|
+
importedMotionsNote: '已导入 {motions} 个动作文件夹{legacy},当前可选 {clips} 支动作。',
|
|
238
|
+
onlyBundledMotions: '当前只有插件自带的动作包。',
|
|
239
|
+
motionsRouteMissing: '宿主没有提供 /vrmpet3d/motions,动作导入在这个版本里不可用。',
|
|
240
|
+
readMotionsFailed: '读取已导入动作失败:宿主没有提供 /vrmpet3d/motions。',
|
|
241
|
+
writeMotionsFailed: '写入失败:宿主没有提供 /vrmpet3d/motions。',
|
|
242
|
+
noFolderPicker: '这个窗口没有文件夹选择器(不是在 DSH 桌面版里打开的),请在下面直接输入动作文件夹的路径。',
|
|
243
|
+
infoFailed: '未能读取模型信息:',
|
|
244
|
+
noLicence: '文件里没有授权信息',
|
|
245
|
+
licenceName: '名称:',
|
|
246
|
+
licenceAuthor: '作者:',
|
|
247
|
+
licencePermission: '使用许可:',
|
|
248
|
+
licenceModification: '修改:',
|
|
249
|
+
licenceCommercial: '商用:',
|
|
250
|
+
licenceCredit: '署名:',
|
|
251
|
+
licenceLicence: '协议:',
|
|
252
|
+
noModel: '没有模型',
|
|
253
|
+
noModelChosen: '当前没有选择模型:宠物不会显示任何东西。上面选一个,设置会立刻生效。',
|
|
254
|
+
notVrm: '非 VRM/GLB',
|
|
255
|
+
meshUnit: ' 网格 / ',
|
|
256
|
+
materialUnit: ' 材质 / ',
|
|
257
|
+
imageUnit: ' 贴图',
|
|
258
|
+
humanoidUnit: ' 人形骨',
|
|
259
|
+
lookAtUnit: '视线:',
|
|
260
|
+
triggerIdle: '空闲',
|
|
261
|
+
triggerDrag: '拖动时',
|
|
262
|
+
triggerClick: '点击时',
|
|
263
|
+
triggerHold: '长按时',
|
|
264
|
+
triggerToggleTitle: '关掉这一行,这个情境就不再触发情绪',
|
|
265
|
+
noExpressions: '这个模型没有可用的表情',
|
|
266
|
+
none: '无',
|
|
267
|
+
selectedExpressions: '已选 {n} 个表情;点开可多选',
|
|
268
|
+
noneRowTitle: '无:这一行不显示任何表情(默认)',
|
|
269
|
+
noneBoxTitle: '不显示任何表情(这一行的默认)',
|
|
270
|
+
holdSecondsTitle: '0 = 一直保持到下一次变化',
|
|
271
|
+
heldSuffix: '(保持)',
|
|
272
|
+
partLabelTitle: '这只宠物对这个部位的说法,随便取',
|
|
273
|
+
regionDefault: '默认',
|
|
274
|
+
partRemove: '删除',
|
|
275
|
+
partRemoveTitle: '删掉这个部位:它的结构会回落到按名字判断的默认部位',
|
|
276
|
+
partLinesAiPlaceholder: '碎碎念:一行一句;留空则由 AI 按这个部位的提示词生成',
|
|
277
|
+
partLinesPlaceholder: '碎碎念:一行一句,摸到这个部位时随机说一句',
|
|
278
|
+
partPromptPlaceholder: 'AI 提示词:切到「AI 碎碎念」时用它生成这个部位的一句话',
|
|
279
|
+
matchExact: '精确',
|
|
280
|
+
matchPrefixWord: '前缀',
|
|
281
|
+
matchExactTitle: '只匹配这个名字本身;点一下变成前缀匹配',
|
|
282
|
+
matchPrefixTitle: '匹配这个名字和它下面的一切(LiuHai.L.002 也算);点一下变成只匹配它本身',
|
|
283
|
+
chipDropTitle: '不再让这个部位拥有这个结构',
|
|
284
|
+
unrecognised: '未识别',
|
|
285
|
+
addStructurePlaceholder: '结构名,回车添加',
|
|
286
|
+
addStructureTitle: '直接输入模型里的结构名,回车添加',
|
|
287
|
+
browse: '浏览…',
|
|
288
|
+
browseTitle: '浏览这个模型的所有结构名(可筛选)',
|
|
289
|
+
addedSuffix: ' · 已加入',
|
|
290
|
+
structuresCount: '{n} / {total} 个结构',
|
|
291
|
+
newPart: '新部位',
|
|
292
|
+
springStiffness: '刚度',
|
|
293
|
+
springDrag: '阻尼',
|
|
294
|
+
springGravity: '重力',
|
|
295
|
+
colliderGroupPrefix: '组 ',
|
|
296
|
+
unnamedBone: '(未命名骨骼)',
|
|
297
|
+
shapeSphere: '球',
|
|
298
|
+
shapeWord: '形状',
|
|
299
|
+
colliderRadiusTitle: '碰撞球半径(模型单位):越大越挡得住,越小越容易被穿过',
|
|
300
|
+
noSprings: '这个模型没有弹簧骨。',
|
|
301
|
+
chainsTotal: '共 {chains} 条链、{joints} 个可调 joint。',
|
|
302
|
+
expandChains: '展开全部 {n} 条弹簧骨链',
|
|
303
|
+
unnamedChainSuffix: '(未命名)',
|
|
304
|
+
unnamedChainPrefix: '未命名链 #',
|
|
305
|
+
entriesListed: '(文件列了 {n} 项)',
|
|
306
|
+
colliderGroupsNote: ' · {n} 碰撞组',
|
|
307
|
+
modelValuePrefix: '模型原值 ',
|
|
308
|
+
packDefaultPrefix: ' · 随包默认 {n}',
|
|
309
|
+
stateDefault: '默认',
|
|
310
|
+
stateChanged: '已改',
|
|
311
|
+
resetChain: '本组复位',
|
|
312
|
+
compressedTag: ' · 已压缩',
|
|
313
|
+
once: '单次',
|
|
314
|
+
loop: '循环',
|
|
315
|
+
dancingOnceTitle: '跳舞一律播放一次,播完回到空闲',
|
|
316
|
+
clipExpressionTitle: '这支动作播放时显示的表情(覆盖情绪表的挑选)',
|
|
317
|
+
clipMouthTitle: '这支动作播放时的口型(覆盖表情自带的口型)',
|
|
318
|
+
followMouth: '口型:跟随表情',
|
|
319
|
+
followExpression: '表情:跟随情绪',
|
|
320
|
+
blankMouth: '口型:默认(无口型)',
|
|
321
|
+
blankExpression: '表情:默认(无表情)',
|
|
322
|
+
mouthPrefix: '口型:',
|
|
323
|
+
expressionPrefix: '表情:',
|
|
324
|
+
motionCount: '({offered} 支可选{active})',
|
|
325
|
+
motionCountActive: ' · 生效 {n} 支',
|
|
326
|
+
bundledNote: '其中 {n} 支随插件分发(assets/motions),不需要导入。',
|
|
327
|
+
packedNote: '其中 {n} 支是压缩存放(.br),播放时自动解压到内存,体积按压缩后的文件计。',
|
|
328
|
+
packedAllNote: '这 {n} 支全部是压缩存放(.br),播放时自动解压到内存,体积按压缩后的文件计。',
|
|
329
|
+
noMotionFiles: '没有可用的动作文件。',
|
|
330
|
+
motionStateCount: '已选 {n} 支',
|
|
331
|
+
motionStateDefaultCount: '随包默认 {n} 支',
|
|
332
|
+
notPlaying: '不播放(已清空)',
|
|
333
|
+
proceduralIdle: '程序化待机',
|
|
334
|
+
folderClips: '{n} 支',
|
|
335
|
+
everyLabel: '单次动作间隔',
|
|
336
|
+
oneShotHere: '(本状态有 {n} 支单次动作)',
|
|
337
|
+
noOneShot: '(本状态还没有勾选「单次」动作,不会触发)',
|
|
338
|
+
folderEmpty: 'assets/motions/{folders} 里还没有 .vrma:把面向这个状态的动作放进那个目录,它们会立刻出现在这里。',
|
|
339
|
+
clipModeTitle: '这支动作在这个状态里播放一次还是循环',
|
|
340
|
+
clipSpeedTitle: '这支动作在这个状态里的播放速度(百分比)',
|
|
341
|
+
speed: '速度',
|
|
342
|
+
clipGazeTitle: '这支动作在这个状态里播放时,是否看向鼠标(关掉就是不看)',
|
|
343
|
+
gazeWord: '凝视',
|
|
344
|
+
notInCatalog: '(不在动作目录中)',
|
|
345
|
+
statusText: '状态文字',
|
|
346
|
+
defaultParen: '(默认)',
|
|
347
|
+
noTextParen: '(不显示文字)',
|
|
348
|
+
statusTextTitle: '这个状态下宠物头顶显示的文字;清空则什么都不显示',
|
|
349
|
+
statusTagTitle: '文字后面的标记',
|
|
350
|
+
noTag: '无标记',
|
|
351
|
+
statusAnimationTitle: '蓝渐变动画就是 DSH 自己的「深度求索中」扫光效果',
|
|
352
|
+
animStatic: '静态',
|
|
353
|
+
animShimmer: '蓝渐变动画',
|
|
354
|
+
waitingDurationTitle: '等待确认不受时间影响:一直显示到你回答完问题 / 确认完权限',
|
|
355
|
+
noticeDurationTitle: '至少显示多久;之后一直留到你切回这个会话(默认 4.2 秒是下限)',
|
|
356
|
+
normalDurationTitle: '这个状态下文字显示多久;0 表示一直显示到状态结束',
|
|
357
|
+
waitingUnit: '秒(此状态不受时间影响)',
|
|
358
|
+
untimedUnit: '秒(至少)',
|
|
359
|
+
normalUnit: '秒(0 = 始终)',
|
|
360
|
+
capWarning: '每个状态最多勾选 {cap} 支动作:{state} 只保留了前 {kept} 支。',
|
|
361
|
+
stateResting: '空闲',
|
|
362
|
+
stateWorking: '工作中',
|
|
363
|
+
stateWaiting: '等待确认',
|
|
364
|
+
stateDone: '任务完成',
|
|
365
|
+
stateError: '出错',
|
|
366
|
+
stateOffline: '离线',
|
|
367
|
+
stateHeld: '拖动中',
|
|
368
|
+
stateFalling: '抛掷 / 下落中',
|
|
369
|
+
stateLanding: '落地',
|
|
370
|
+
stateDancing: '跳舞',
|
|
371
|
+
speechStateResting: '空闲 / 静息态',
|
|
372
|
+
emptyLinesBuiltin: '用内置短语',
|
|
373
|
+
emptyLinesAi: '用内置 AI 生成提示词',
|
|
374
|
+
emptyLinesPlaceholderBuiltin: '留空则用内置短语',
|
|
375
|
+
emptyLinesPlaceholderAi: '留空则用内置 AI 提示词生成一句(生成期间先显示内置短语)',
|
|
376
|
+
linesLabel: '台词(一行一条)',
|
|
377
|
+
promptLabel: 'AI 生成提示词',
|
|
378
|
+
defaultSpeechPrompt: '现在说一句。',
|
|
379
|
+
lineCount: '{n} 条',
|
|
380
|
+
llmAvailable: 'DSH 的 LLM 服务可用({n} 个 provider)',
|
|
381
|
+
llmUnavailable: '这个 DSH 没有可用的 LLM 服务,AI 碎碎念只能用自定义 Provider',
|
|
382
|
+
noProviders: '还没有配置任何 provider',
|
|
383
|
+
providerNoModels: '这个 provider 没有返回任何模型',
|
|
384
|
+
readFailedPrefix: '读取失败:',
|
|
385
|
+
customProviderNote: '自定义 Provider 用 OpenAI 兼容的 /chat/completions。',
|
|
386
|
+
noteJoin: ';',
|
|
387
|
+
llmNoAnswer: '读不到 provider 列表(宿主没有回应)。',
|
|
388
|
+
generating: '生成中…',
|
|
389
|
+
failedPrefix: '失败:',
|
|
390
|
+
hostNoAnswer: '宿主没有回应',
|
|
391
|
+
quoteOpen: '「',
|
|
392
|
+
quoteClose: '」',
|
|
393
|
+
msParen: '({ms} ms)',
|
|
394
|
+
straightLine: '(直线)',
|
|
395
|
+
globalRadius: '(全局)',
|
|
396
|
+
floatingUnavailable: '悬浮窗不可用(宿主没有提供 /vrmpet3d/desktop);app 内的宠物仍会跟随这些设置。',
|
|
397
|
+
statusModel: '模型 {model}(两个界面共用)',
|
|
398
|
+
statusWindow: '悬浮窗 {state}',
|
|
399
|
+
windowRunning: '运行中',
|
|
400
|
+
windowOff: '未开启',
|
|
401
|
+
windowRenderedReady: '悬浮窗运行中(已渲染,模型 {model})',
|
|
402
|
+
windowRenderedNotReady: '悬浮窗运行中(页面还没就绪)',
|
|
403
|
+
windowNotOpen: '悬浮窗未开启',
|
|
404
|
+
currentModelLine: '当前模型:{model} · {window} · app 内宠物同样跟随设置',
|
|
405
|
+
spanNow: '(当前 {px} px)',
|
|
406
|
+
noScreenSize: '(宿主没有报告屏幕尺寸)',
|
|
407
|
+
statusReadFailed: '未能读取悬浮窗状态;改动仍会写入设置。',
|
|
408
|
+
gazeNotRunning: '悬浮窗未开启:看不到实时数值,但设置已经写入,app 内的宠物同样使用它。',
|
|
409
|
+
gazeNoReport: '悬浮窗还没有报告视线数值(重启悬浮窗后这里会显示它实际使用的曲率与朝向)。',
|
|
410
|
+
gazeApplied: '渲染器正在使用:曲率 {curve} · 满偏 {spanX}×{spanY} px · 当前视线 {aimX}, {aimY}(每 2 秒读取一次;如果它随滑杆变化,就说明改动已经到达渲染器)',
|
|
411
|
+
setupOpenedTab: '已在新标签页里打开引导。',
|
|
412
|
+
setupFallbackTab: '启动器没能打开窗口,改在新标签页里打开。',
|
|
413
|
+
setupOpenedWindow: '引导已在新窗口里打开。',
|
|
414
|
+
setupNoHost: '宿主没有提供 /vrmpet3d/desktop。',
|
|
415
|
+
settingsUnsupported: '当前宿主没有 /vrmpet3d/settings:设置只保存在这个页面所在的存储里。',
|
|
416
|
+
},
|
|
417
|
+
en: {
|
|
418
|
+
// ---- the chrome, and the name each page gives itself ----
|
|
419
|
+
titleModel: 'vrmpet3d · Model parameters',
|
|
420
|
+
titleGlobal: 'vrmpet3d · General settings',
|
|
421
|
+
headingModel: 'Model parameters',
|
|
422
|
+
headingGlobal: 'General settings',
|
|
423
|
+
loading: 'Loading settings…',
|
|
424
|
+
reading: 'Reading…',
|
|
425
|
+
navSections: 'Settings sections',
|
|
426
|
+
// ---- the section names, which are also the nav entries ----
|
|
427
|
+
sectionModel: 'Model',
|
|
428
|
+
sectionExpression: 'Expressions',
|
|
429
|
+
sectionParts: 'Body parts',
|
|
430
|
+
sectionSprings: 'Spring bones',
|
|
431
|
+
sectionDisplay: 'Display',
|
|
432
|
+
sectionGaze: 'Gaze',
|
|
433
|
+
sectionMouse: 'Pointer physics',
|
|
434
|
+
sectionState: 'States and clips',
|
|
435
|
+
sectionSpeech: 'Chit-chat',
|
|
436
|
+
sectionModelImport: 'Model import',
|
|
437
|
+
sectionMotionImport: 'Motion import',
|
|
438
|
+
sectionOther: 'Other',
|
|
439
|
+
// ---- words a row repeats ----
|
|
440
|
+
on: 'On',
|
|
441
|
+
reset: 'Reset',
|
|
442
|
+
close: 'Close',
|
|
443
|
+
auto: 'Auto',
|
|
444
|
+
secondsUnit: 's',
|
|
445
|
+
remove: 'Remove',
|
|
446
|
+
// ---- 界面语言 (the language row) ----
|
|
447
|
+
language: 'Interface language',
|
|
448
|
+
languageAuto: 'Auto',
|
|
449
|
+
languageZh: '中文',
|
|
450
|
+
languageEn: 'English',
|
|
451
|
+
// ---- the model page: model and expressions ----
|
|
452
|
+
currentModel: 'Current model',
|
|
453
|
+
availableInfo: 'Details',
|
|
454
|
+
blink: 'Blink',
|
|
455
|
+
interval: 'Interval',
|
|
456
|
+
emotion: 'Emotion',
|
|
457
|
+
idleInterval: 'Idle interval',
|
|
458
|
+
colSituation: 'Situation',
|
|
459
|
+
colUse: 'Use',
|
|
460
|
+
colExpression: 'Expression',
|
|
461
|
+
colHold: 'Duration',
|
|
462
|
+
// ---- the model page: body parts ----
|
|
463
|
+
partsExpand: 'Show the part list',
|
|
464
|
+
colPart: 'Part',
|
|
465
|
+
colStructure: 'Structure',
|
|
466
|
+
partsAdd: 'Add part',
|
|
467
|
+
partsMaxPrefix: 'at most',
|
|
468
|
+
partsMaxSuffix: '',
|
|
469
|
+
filterStructures: 'Filter structure names',
|
|
470
|
+
// ---- the model page: spring bones ----
|
|
471
|
+
springMotionWhile: 'While a clip plays',
|
|
472
|
+
springMotionBox: 'Hair and skirt keep swinging',
|
|
473
|
+
springColliders: 'Spring-bone collisions',
|
|
474
|
+
collidersOff: 'All off, which is the default',
|
|
475
|
+
collidersAll: 'All on',
|
|
476
|
+
collidersCustom: 'Custom',
|
|
477
|
+
collidersDetail: 'Per collider',
|
|
478
|
+
springResetAll: 'Reset everything',
|
|
479
|
+
// ---- 通用设置: display ----
|
|
480
|
+
petSurface: 'Pet surface',
|
|
481
|
+
surfaceApp: 'Inside the app',
|
|
482
|
+
sizeApp: 'In-app size',
|
|
483
|
+
sizeWindow: 'Window size',
|
|
484
|
+
idleNap: 'Idle nap',
|
|
485
|
+
sleep30: '30 s',
|
|
486
|
+
sleep120: '2 min',
|
|
487
|
+
sleep300: '5 min',
|
|
488
|
+
debugMode: 'Debug mode',
|
|
489
|
+
debugModeBox: 'Show the floating and in-app pets together',
|
|
490
|
+
view: 'View',
|
|
491
|
+
viewReset: 'Reset view',
|
|
492
|
+
pet: 'Pet',
|
|
493
|
+
hide: 'Hide',
|
|
494
|
+
// ---- 通用设置: gaze ----
|
|
495
|
+
lookAtPointer: 'Look at the cursor',
|
|
496
|
+
curve: 'Curve',
|
|
497
|
+
span: 'Full deflection',
|
|
498
|
+
pxHorizontal: 'px horizontal',
|
|
499
|
+
pxVertical: 'px vertical',
|
|
500
|
+
gazeIdle: 'Stop while still',
|
|
501
|
+
readingWindow: 'Reading the floating window…',
|
|
502
|
+
// ---- 通用设置: pointer physics ----
|
|
503
|
+
springPhysics: 'Spring-bone physics',
|
|
504
|
+
windPower: 'Stroke strength',
|
|
505
|
+
windRadius: 'Radius',
|
|
506
|
+
windDecay: 'Afterglow',
|
|
507
|
+
windSpeed: 'Full-force speed',
|
|
508
|
+
pxPerSecond: 'px/s',
|
|
509
|
+
// ---- 通用设置: states and clips ----
|
|
510
|
+
playMotion: 'Play clips',
|
|
511
|
+
// ---- 通用设置: chit-chat, the markup ----
|
|
512
|
+
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.',
|
|
513
|
+
speechEnable: 'Enable',
|
|
514
|
+
speechTalk: 'Talk',
|
|
515
|
+
speechHold: 'Show each line',
|
|
516
|
+
speechGap: 'Shortest gap',
|
|
517
|
+
speechIdleInterval: 'Idle murmur interval',
|
|
518
|
+
speechWorkInterval: 'Working murmur interval',
|
|
519
|
+
speechSource: 'Line source',
|
|
520
|
+
sourceBuiltin: 'Built-in phrases',
|
|
521
|
+
sourceCustom: 'Custom phrases',
|
|
522
|
+
sourceAi: 'AI generated',
|
|
523
|
+
speechAiFrom: 'AI source',
|
|
524
|
+
providerDsh: 'DSH Provider',
|
|
525
|
+
providerCustom: 'Custom provider',
|
|
526
|
+
speechModel: 'Model',
|
|
527
|
+
speechKeyClear: 'Clear',
|
|
528
|
+
persona: 'Persona',
|
|
529
|
+
temperature: 'Temperature',
|
|
530
|
+
timeout: 'Timeout',
|
|
531
|
+
speechTest: 'Try a line',
|
|
532
|
+
generate: 'Generate',
|
|
533
|
+
// ---- 通用设置: imports ----
|
|
534
|
+
importModel: 'Import model',
|
|
535
|
+
pickModelFile: 'Choose a model file…',
|
|
536
|
+
manualPath: 'Type a path',
|
|
537
|
+
filePath: 'File path',
|
|
538
|
+
import: 'Import',
|
|
539
|
+
importFolder: 'Import folder',
|
|
540
|
+
pickMotionDir: 'Choose a folder…',
|
|
541
|
+
path: 'Path',
|
|
542
|
+
add: 'Add',
|
|
543
|
+
// ---- 通用设置: other ----
|
|
544
|
+
setupSection: 'First-run guide',
|
|
545
|
+
setupRerun: 'Run the guide again',
|
|
546
|
+
// ---- the script's own words ----
|
|
547
|
+
writeFailed: 'Could not write the settings: ',
|
|
548
|
+
readTimeout: 'Timed out reading the settings: the host did not answer within 8 seconds; everything else can still be retried by hand.',
|
|
549
|
+
catalogFailed: 'Could not read the model list: the host does not serve /vrmpet3d/catalog',
|
|
550
|
+
importTag: 'Imported',
|
|
551
|
+
legacyDir: 'Legacy folder',
|
|
552
|
+
importedModelsNote: 'Imported {models} model file(s){legacy}; the model list now holds {total} models.',
|
|
553
|
+
legacyDirsClause: ' and {dirs} legacy folder(s)',
|
|
554
|
+
onlyBundledModels: 'The model list holds only the plugin bundled models.',
|
|
555
|
+
modelsRouteMissing: 'The host does not serve /vrmpet3d/models, so model import is unavailable in this build.',
|
|
556
|
+
readModelsFailed: 'Could not read the imported models: the host does not serve /vrmpet3d/models.',
|
|
557
|
+
importFailedHttp: 'Import failed, HTTP {status}',
|
|
558
|
+
persistFailedClause: 'The import took effect, but the list file could not be written ({reason}); it will be lost on restart.',
|
|
559
|
+
unknownReason: 'unknown reason',
|
|
560
|
+
writeModelsFailed: 'Write failed: the host does not serve /vrmpet3d/models.',
|
|
561
|
+
noFilePicker: 'This window has no file chooser, because it is not open inside DSH Desktop, so type the model file path below.',
|
|
562
|
+
motionImportedValue: '{name} — {clips} clips',
|
|
563
|
+
importedMotionsNote: 'Imported {motions} motion folder(s){legacy}; {clips} clips are available.',
|
|
564
|
+
onlyBundledMotions: 'Only the plugin bundled motion pack is present.',
|
|
565
|
+
motionsRouteMissing: 'The host does not serve /vrmpet3d/motions, so motion import is unavailable in this build.',
|
|
566
|
+
readMotionsFailed: 'Could not read the imported motions: the host does not serve /vrmpet3d/motions.',
|
|
567
|
+
writeMotionsFailed: 'Write failed: the host does not serve /vrmpet3d/motions.',
|
|
568
|
+
noFolderPicker: 'This window has no folder chooser, because it is not open inside DSH Desktop, so type the motion folder path below.',
|
|
569
|
+
infoFailed: 'Could not read the model info: ',
|
|
570
|
+
noLicence: 'no licence information in the file',
|
|
571
|
+
licenceName: 'Name: ',
|
|
572
|
+
licenceAuthor: 'Author: ',
|
|
573
|
+
licencePermission: 'Usage permission: ',
|
|
574
|
+
licenceModification: 'Modification: ',
|
|
575
|
+
licenceCommercial: 'Commercial use: ',
|
|
576
|
+
licenceCredit: 'Credit: ',
|
|
577
|
+
licenceLicence: 'Licence: ',
|
|
578
|
+
noModel: 'No model',
|
|
579
|
+
noModelChosen: 'No model is selected: the pet will draw nothing. Pick one above and the change takes effect at once.',
|
|
580
|
+
notVrm: 'Not VRM/GLB',
|
|
581
|
+
meshUnit: ' meshes / ',
|
|
582
|
+
materialUnit: ' materials / ',
|
|
583
|
+
imageUnit: ' textures',
|
|
584
|
+
humanoidUnit: ' humanoid bones',
|
|
585
|
+
lookAtUnit: 'Look-at: ',
|
|
586
|
+
triggerIdle: 'Idle',
|
|
587
|
+
triggerDrag: 'While dragged',
|
|
588
|
+
triggerClick: 'On click',
|
|
589
|
+
triggerHold: 'On long press',
|
|
590
|
+
triggerToggleTitle: 'Untick this row and the situation stops triggering emotions',
|
|
591
|
+
noExpressions: 'This model has no usable expressions',
|
|
592
|
+
none: 'None',
|
|
593
|
+
selectedExpressions: 'Selected {n}; open to pick several',
|
|
594
|
+
noneRowTitle: 'None: this row shows no expression, which is the default',
|
|
595
|
+
noneBoxTitle: 'Show no expression: the default for this row',
|
|
596
|
+
holdSecondsTitle: '0 = hold until the next change',
|
|
597
|
+
heldSuffix: ' (held)',
|
|
598
|
+
partLabelTitle: 'What this pet calls this part; take any name you like',
|
|
599
|
+
regionDefault: 'default',
|
|
600
|
+
partRemove: 'Delete',
|
|
601
|
+
partRemoveTitle: 'Delete this part: its structures fall back to the name-based default region',
|
|
602
|
+
partLinesAiPlaceholder: 'Chit-chat: one line each; leave it empty and the AI writes from this part prompt',
|
|
603
|
+
partLinesPlaceholder: 'Chit-chat: one line each, said at random when this part is touched',
|
|
604
|
+
partPromptPlaceholder: 'AI prompt: used to generate one line about this part when AI chit-chat is on',
|
|
605
|
+
matchExact: 'Exact',
|
|
606
|
+
matchPrefixWord: 'Prefix',
|
|
607
|
+
matchExactTitle: 'Matches this name alone; click to switch to prefix matching',
|
|
608
|
+
matchPrefixTitle: 'Matches this name and everything under it, LiuHai.L.002 included; click to match the name alone',
|
|
609
|
+
chipDropTitle: 'Stop this part from owning this structure',
|
|
610
|
+
unrecognised: 'unrecognised',
|
|
611
|
+
addStructurePlaceholder: 'Structure name, Enter to add',
|
|
612
|
+
addStructureTitle: 'Type a structure name from the model and press Enter',
|
|
613
|
+
browse: 'Browse…',
|
|
614
|
+
browseTitle: 'Browse every structure name in this model, filterable',
|
|
615
|
+
addedSuffix: ' · added',
|
|
616
|
+
structuresCount: '{n} / {total} structures',
|
|
617
|
+
newPart: 'New part',
|
|
618
|
+
springStiffness: 'Stiffness',
|
|
619
|
+
springDrag: 'Damping',
|
|
620
|
+
springGravity: 'Gravity',
|
|
621
|
+
colliderGroupPrefix: 'group ',
|
|
622
|
+
unnamedBone: 'unnamed bone',
|
|
623
|
+
shapeSphere: 'sphere',
|
|
624
|
+
shapeWord: 'shape',
|
|
625
|
+
colliderRadiusTitle: 'Collider radius in model units: larger blocks more, smaller is easier to pass through',
|
|
626
|
+
noSprings: 'This model has no spring bones.',
|
|
627
|
+
chainsTotal: '{chains} chains, {joints} adjustable joints.',
|
|
628
|
+
expandChains: 'Show all {n} spring-bone chains',
|
|
629
|
+
unnamedChainSuffix: ' (unnamed)',
|
|
630
|
+
unnamedChainPrefix: 'Unnamed chain #',
|
|
631
|
+
entriesListed: 'the file lists {n}',
|
|
632
|
+
colliderGroupsNote: ' · {n} collider groups',
|
|
633
|
+
modelValuePrefix: 'file value ',
|
|
634
|
+
packDefaultPrefix: ' · pack default {n}',
|
|
635
|
+
stateDefault: 'Default',
|
|
636
|
+
stateChanged: 'Changed',
|
|
637
|
+
resetChain: 'Reset chain',
|
|
638
|
+
compressedTag: ' · compressed',
|
|
639
|
+
once: 'Once',
|
|
640
|
+
loop: 'Loop',
|
|
641
|
+
dancingOnceTitle: 'Dancing always plays once and then returns to idle',
|
|
642
|
+
clipExpressionTitle: 'The expression shown while this clip plays, overriding the mood table',
|
|
643
|
+
clipMouthTitle: 'The mouth shape while this clip plays, overriding the expression own',
|
|
644
|
+
followMouth: 'Mouth: follow the expression',
|
|
645
|
+
followExpression: 'Expression: follow the mood',
|
|
646
|
+
blankMouth: 'Mouth: default, no mouth shape',
|
|
647
|
+
blankExpression: 'Expression: default, no expression',
|
|
648
|
+
mouthPrefix: 'Mouth: ',
|
|
649
|
+
expressionPrefix: 'Expression: ',
|
|
650
|
+
motionCount: '{offered} clips offered{active}',
|
|
651
|
+
motionCountActive: ' · active {n}',
|
|
652
|
+
bundledNote: '{n} of them ship with the plugin under assets/motions and need no import.',
|
|
653
|
+
packedNote: '{n} of them are stored compressed as .br and unpacked into memory on playback; the size shown is the compressed file.',
|
|
654
|
+
packedAllNote: 'All {n} clips are stored compressed as .br and unpacked into memory on playback; the size shown is the compressed file.',
|
|
655
|
+
noMotionFiles: 'There are no usable motion files.',
|
|
656
|
+
motionStateCount: 'Selected {n}',
|
|
657
|
+
motionStateDefaultCount: 'Pack default {n}',
|
|
658
|
+
notPlaying: 'Nothing plays, cleared',
|
|
659
|
+
proceduralIdle: 'Procedural idle',
|
|
660
|
+
folderClips: '{n} clips',
|
|
661
|
+
everyLabel: 'One-shot interval',
|
|
662
|
+
oneShotHere: '{n} one-shot clips in this state',
|
|
663
|
+
noOneShot: 'no one-shot clip is ticked here, so this never fires',
|
|
664
|
+
folderEmpty: 'assets/motions/{folders} holds no .vrma yet: drop clips for this state into that folder and they appear here at once.',
|
|
665
|
+
clipModeTitle: 'Whether this clip plays once or loops in this state',
|
|
666
|
+
clipSpeedTitle: 'Playback speed for this clip in this state, in percent',
|
|
667
|
+
speed: 'Speed',
|
|
668
|
+
clipGazeTitle: 'Whether the pet looks at the pointer while this clip plays in this state',
|
|
669
|
+
gazeWord: 'Gaze',
|
|
670
|
+
notInCatalog: ' (not in the motion catalogue)',
|
|
671
|
+
statusText: 'Status text',
|
|
672
|
+
defaultParen: ' (default)',
|
|
673
|
+
noTextParen: '(no text)',
|
|
674
|
+
statusTextTitle: 'The text shown over the pet in this state; clearing it shows nothing',
|
|
675
|
+
statusTagTitle: 'The marker after the text',
|
|
676
|
+
noTag: 'No marker',
|
|
677
|
+
statusAnimationTitle: 'The blue shimmer is DSH own working sweep effect',
|
|
678
|
+
animStatic: 'Static',
|
|
679
|
+
animShimmer: 'Blue shimmer',
|
|
680
|
+
waitingDurationTitle: 'Waiting is untimed: it stays until you answer the question or confirm the permission',
|
|
681
|
+
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',
|
|
682
|
+
normalDurationTitle: 'How long the text shows in this state; 0 means until the state ends',
|
|
683
|
+
waitingUnit: 's, this state ignores the clock',
|
|
684
|
+
untimedUnit: 's, minimum',
|
|
685
|
+
normalUnit: 's, 0 = always',
|
|
686
|
+
capWarning: 'At most {cap} clips per state: {state} kept the first {kept}.',
|
|
687
|
+
stateResting: 'Idle',
|
|
688
|
+
stateWorking: 'Working',
|
|
689
|
+
stateWaiting: 'Waiting for you',
|
|
690
|
+
stateDone: 'Task complete',
|
|
691
|
+
stateError: 'Error',
|
|
692
|
+
stateOffline: 'Offline',
|
|
693
|
+
stateHeld: 'Being dragged',
|
|
694
|
+
stateFalling: 'Thrown / falling',
|
|
695
|
+
stateLanding: 'Landing',
|
|
696
|
+
stateDancing: 'Dancing',
|
|
697
|
+
speechStateResting: 'Idle / resting',
|
|
698
|
+
emptyLinesBuiltin: 'uses the built-in phrases',
|
|
699
|
+
emptyLinesAi: 'generated from the built-in AI prompt',
|
|
700
|
+
emptyLinesPlaceholderBuiltin: 'Leave empty to use the built-in phrases',
|
|
701
|
+
emptyLinesPlaceholderAi: 'Leave empty to generate a line from the built-in AI prompt; the built-in phrase shows while it generates',
|
|
702
|
+
linesLabel: 'Lines, one per line',
|
|
703
|
+
promptLabel: 'AI generation prompt',
|
|
704
|
+
defaultSpeechPrompt: 'Say a line now.',
|
|
705
|
+
lineCount: '{n} lines',
|
|
706
|
+
llmAvailable: 'DSH LLM service is available, {n} providers',
|
|
707
|
+
llmUnavailable: 'This DSH has no usable LLM service, so AI chit-chat can only use a custom provider',
|
|
708
|
+
noProviders: 'no provider is configured yet',
|
|
709
|
+
providerNoModels: 'this provider returned no models',
|
|
710
|
+
readFailedPrefix: 'Read failed: ',
|
|
711
|
+
customProviderNote: 'A custom provider uses an OpenAI-compatible /chat/completions.',
|
|
712
|
+
noteJoin: '; ',
|
|
713
|
+
llmNoAnswer: 'Could not read the provider list; the host did not answer.',
|
|
714
|
+
generating: 'Generating…',
|
|
715
|
+
failedPrefix: 'Failed: ',
|
|
716
|
+
hostNoAnswer: 'the host did not answer',
|
|
717
|
+
quoteOpen: '“',
|
|
718
|
+
quoteClose: '”',
|
|
719
|
+
msParen: '{ms} ms',
|
|
720
|
+
straightLine: ' (straight)',
|
|
721
|
+
globalRadius: ' (global)',
|
|
722
|
+
floatingUnavailable: 'The floating window is unavailable: the host does not serve /vrmpet3d/desktop. The in-app pet still follows these settings.',
|
|
723
|
+
statusModel: 'Model {model}, shared by both surfaces',
|
|
724
|
+
statusWindow: 'Window {state}',
|
|
725
|
+
windowRunning: 'running',
|
|
726
|
+
windowOff: 'not open',
|
|
727
|
+
windowRenderedReady: 'The floating window is running, rendered, model {model}',
|
|
728
|
+
windowRenderedNotReady: 'The floating window is running, but the page is not ready yet',
|
|
729
|
+
windowNotOpen: 'The floating window is not open',
|
|
730
|
+
currentModelLine: 'Current model: {model} · {window} · the in-app pet follows the same settings',
|
|
731
|
+
spanNow: 'currently {px} px',
|
|
732
|
+
noScreenSize: 'the host did not report a screen size',
|
|
733
|
+
statusReadFailed: 'Could not read the floating window status; changes are still written to the settings.',
|
|
734
|
+
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.',
|
|
735
|
+
gazeNoReport: 'The floating window has not reported its gaze numbers yet; restart it and this line shows the curve and aim it really uses.',
|
|
736
|
+
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',
|
|
737
|
+
setupOpenedTab: 'The guide opened in a new tab.',
|
|
738
|
+
setupFallbackTab: 'The launcher could not open a window, so the guide opened in a new tab instead.',
|
|
739
|
+
setupOpenedWindow: 'The guide opened in a new window.',
|
|
740
|
+
setupNoHost: 'The host does not serve /vrmpet3d/desktop.',
|
|
741
|
+
settingsUnsupported: 'This host does not serve /vrmpet3d/settings: the settings live only in this page own storage.',
|
|
742
|
+
},
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
/** The language this page draws in — resolved once per render pass by `applyLanguage`, read by `t` at call time. */
|
|
746
|
+
var language = 'zh';
|
|
747
|
+
|
|
748
|
+
/** Whether the static markup has been swept once — see `applyLanguage`, which sweeps at boot and on a CHANGE only. */
|
|
749
|
+
var languagePainted = false;
|
|
750
|
+
|
|
751
|
+
/** One entry of `TEXT` in the CURRENT language; the KEY comes back when the table has no entry, so a gap shows. */
|
|
752
|
+
function t(key) {
|
|
753
|
+
return core.textOf(TEXT, language, key);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* `{token}` substitution for the sentences that carry a number or a name inside them.
|
|
758
|
+
*
|
|
759
|
+
* A whole sentence with a placeholder rather than three concatenated fragments, because the fragments do not sit in
|
|
760
|
+
* the same order in the two languages — and `core.applyI18n` cannot do this part: it writes a string verbatim, which
|
|
761
|
+
* is exactly what the marked-up labels want.
|
|
762
|
+
*/
|
|
763
|
+
function fill(template, values) {
|
|
764
|
+
var out = String(template);
|
|
765
|
+
Object.keys(values || {}).forEach(function (key) {
|
|
766
|
+
out = out.split('{' + key + '}').join(String(values[key]));
|
|
767
|
+
});
|
|
768
|
+
return out;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Paint the page in the language this profile resolves to, and say whether it CHANGED.
|
|
773
|
+
*
|
|
774
|
+
* The static markup is one `core.applyI18n` sweep — `data-i18n` writes text, `-html` writes our own sentence with a
|
|
775
|
+
* `<strong>` in it, `-placeholder`/`-aria` write those attributes — and both documents put it on their `<title>` and
|
|
776
|
+
* their nav as well, so nothing here is special-cased.
|
|
777
|
+
*
|
|
778
|
+
* `documentElement.lang` follows the RESOLVED language rather than staying at the markup's `zh`: the pages ship
|
|
779
|
+
* `lang="zh"` as their no-JS fallback, and a page that kept claiming Chinese while drawing English would also feed
|
|
780
|
+
* that lie back through `core.browserLanguageHints`' second hint.
|
|
781
|
+
*
|
|
782
|
+
* The resolved language is stored in `language` (which `t` reads) and compared with the last one, because the
|
|
783
|
+
* snapshot handler polls the host: only a REAL change repaints the built half of the page (`repaintLanguage`).
|
|
784
|
+
*
|
|
785
|
+
* The SWEEP itself is also gated on that comparison, and it has to be: `#status` is a marked node (its placeholder
|
|
786
|
+
* is 「正在读取…」), so sweeping on every poll would rewrite the live status line every few hundred milliseconds and
|
|
787
|
+
* leave the host's own sentence replaced by "reading…" for as long as the poll's fetch took. It runs at boot and on
|
|
788
|
+
* a change — which is exactly what the language needs and nothing more.
|
|
789
|
+
*/
|
|
790
|
+
function applyLanguage() {
|
|
791
|
+
var lang = core.resolveLanguage(preferences.language, core.browserLanguageHints());
|
|
792
|
+
var changed = lang !== language;
|
|
793
|
+
language = lang;
|
|
794
|
+
if (document.documentElement) document.documentElement.lang = lang;
|
|
795
|
+
if (changed || !languagePainted) {
|
|
796
|
+
languagePainted = true;
|
|
797
|
+
core.applyI18n(document, TEXT, lang);
|
|
798
|
+
}
|
|
799
|
+
return changed;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Re-emit everything a language change has to reach that `core.applyI18n` cannot.
|
|
804
|
+
*
|
|
805
|
+
* `renderAll` covers the sections built from `preferences`; the two import lists are drawn from their own cached
|
|
806
|
+
* payloads (re-rendering them from a snapshot would fight a list the user is working in, which is why they are NOT
|
|
807
|
+
* part of the ordinary repaint) and the 碎碎念 rows build their labels once, so those get their own hops here.
|
|
808
|
+
*/
|
|
809
|
+
function repaintLanguage() {
|
|
810
|
+
renderAll();
|
|
811
|
+
relabelSpeechStates();
|
|
812
|
+
renderImported();
|
|
813
|
+
renderImportedMotions();
|
|
814
|
+
}
|
|
815
|
+
|
|
60
816
|
/**
|
|
61
817
|
* The local cache of the settings, under the CURRENT key or one this project used before it was renamed.
|
|
62
818
|
*
|
|
@@ -159,7 +915,7 @@
|
|
|
159
915
|
try {
|
|
160
916
|
globalThis.localStorage.setItem(STORE, JSON.stringify(preferences));
|
|
161
917
|
} catch (error) {
|
|
162
|
-
setStatus('
|
|
918
|
+
setStatus(t('writeFailed') + (error && error.message));
|
|
163
919
|
}
|
|
164
920
|
if (!hostSync) return;
|
|
165
921
|
var patch = {};
|
|
@@ -203,7 +959,7 @@
|
|
|
203
959
|
globalThis.setTimeout(function () {
|
|
204
960
|
if (!loading) return;
|
|
205
961
|
setLoading(true);
|
|
206
|
-
setStatus('
|
|
962
|
+
setStatus(t('readTimeout'), 'warn');
|
|
207
963
|
}, 8000);
|
|
208
964
|
|
|
209
965
|
function setStatus(text, kind) {
|
|
@@ -324,7 +1080,7 @@
|
|
|
324
1080
|
.catch(function () {
|
|
325
1081
|
// A failure is an ANSWER: the cover comes off so the reason in `#status` can be read, and nothing spins on.
|
|
326
1082
|
setLoading(true);
|
|
327
|
-
setStatus('
|
|
1083
|
+
setStatus(t('catalogFailed'), 'warn');
|
|
328
1084
|
});
|
|
329
1085
|
}
|
|
330
1086
|
|
|
@@ -359,7 +1115,7 @@
|
|
|
359
1115
|
text.title = title || value;
|
|
360
1116
|
var remove = document.createElement('button');
|
|
361
1117
|
remove.className = 'ghost model-import-remove' + (legacy ? ' legacy' : '');
|
|
362
|
-
remove.textContent = '
|
|
1118
|
+
remove.textContent = t('remove');
|
|
363
1119
|
remove.addEventListener('click', function () { onRemove(); });
|
|
364
1120
|
row.appendChild(key);
|
|
365
1121
|
row.appendChild(text);
|
|
@@ -375,11 +1131,11 @@
|
|
|
375
1131
|
imported.models.forEach(function (entry) {
|
|
376
1132
|
// The SOURCE is what the user recognises ("the one I picked out of my Downloads"), while the copy's own path
|
|
377
1133
|
// is what gets removed — so the row shows the former and acts on the latter.
|
|
378
|
-
list.appendChild(importedRow('
|
|
1134
|
+
list.appendChild(importedRow(t('importTag'), entry.name, entry.source || entry.file,
|
|
379
1135
|
function () { removeImported(entry.file); }));
|
|
380
1136
|
});
|
|
381
1137
|
imported.dirs.forEach(function (dir) {
|
|
382
|
-
list.appendChild(importedRow('
|
|
1138
|
+
list.appendChild(importedRow(t('legacyDir'), dir, dir, function () { removeImportedDir(dir); }, true));
|
|
383
1139
|
});
|
|
384
1140
|
if (!note) return;
|
|
385
1141
|
if (imported.error) {
|
|
@@ -390,10 +1146,12 @@
|
|
|
390
1146
|
note.className = 'hint';
|
|
391
1147
|
var total = imported.models.length + imported.dirs.length;
|
|
392
1148
|
note.textContent = total
|
|
393
|
-
? '
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
1149
|
+
? fill(t('importedModelsNote'), {
|
|
1150
|
+
models: imported.models.length,
|
|
1151
|
+
legacy: imported.dirs.length ? fill(t('legacyDirsClause'), { dirs: imported.dirs.length }) : '',
|
|
1152
|
+
total: imported.modelCount,
|
|
1153
|
+
})
|
|
1154
|
+
: t('onlyBundledModels');
|
|
397
1155
|
}
|
|
398
1156
|
|
|
399
1157
|
function loadImported() {
|
|
@@ -404,12 +1162,12 @@
|
|
|
404
1162
|
imported.dirs = body && Array.isArray(body.importedDirs) ? body.importedDirs : [];
|
|
405
1163
|
imported.modelCount = body && Number.isFinite(Number(body.modelCount)) ? Number(body.modelCount) : 0;
|
|
406
1164
|
imported.catalogFile = body && body.catalogFile ? body.catalogFile : null;
|
|
407
|
-
imported.error = body ? null : '
|
|
1165
|
+
imported.error = body ? null : t('modelsRouteMissing');
|
|
408
1166
|
renderImported();
|
|
409
1167
|
return imported;
|
|
410
1168
|
})
|
|
411
1169
|
.catch(function () {
|
|
412
|
-
imported.error = '
|
|
1170
|
+
imported.error = t('readModelsFailed');
|
|
413
1171
|
renderImported();
|
|
414
1172
|
return imported;
|
|
415
1173
|
});
|
|
@@ -429,7 +1187,7 @@
|
|
|
429
1187
|
}).then(function (response) {
|
|
430
1188
|
if (!response.ok) {
|
|
431
1189
|
return response.text().then(function (text) {
|
|
432
|
-
imported.error = text || ('
|
|
1190
|
+
imported.error = text || fill(t('importFailedHttp'), { status: response.status });
|
|
433
1191
|
renderImported();
|
|
434
1192
|
return null;
|
|
435
1193
|
});
|
|
@@ -439,14 +1197,14 @@
|
|
|
439
1197
|
imported.dirs = Array.isArray(payload.importedDirs) ? payload.importedDirs : imported.dirs;
|
|
440
1198
|
imported.modelCount = Number(payload.modelCount) || 0;
|
|
441
1199
|
imported.error = payload.persisted === false
|
|
442
|
-
? '
|
|
1200
|
+
? fill(t('persistFailedClause'), { reason: payload.persistError || t('unknownReason') })
|
|
443
1201
|
: null;
|
|
444
1202
|
renderImported();
|
|
445
1203
|
// The catalog is rebuilt on every request, so the model picker picks the new models up immediately.
|
|
446
1204
|
return loadCatalog();
|
|
447
1205
|
});
|
|
448
1206
|
}).catch(function () {
|
|
449
|
-
imported.error = '
|
|
1207
|
+
imported.error = t('writeModelsFailed');
|
|
450
1208
|
renderImported();
|
|
451
1209
|
return null;
|
|
452
1210
|
});
|
|
@@ -483,11 +1241,11 @@
|
|
|
483
1241
|
if (!picker) {
|
|
484
1242
|
// No launcher bridge: the typed path IS the import UI, so reveal it rather than failing silently.
|
|
485
1243
|
if (manualRow) manualRow.hidden = false;
|
|
486
|
-
imported.error = '
|
|
1244
|
+
imported.error = t('noFilePicker');
|
|
487
1245
|
renderImported();
|
|
488
1246
|
return;
|
|
489
1247
|
}
|
|
490
|
-
Promise.resolve(picker()).then(function (chosen) { return addImported(chosen); });
|
|
1248
|
+
Promise.resolve(picker(language)).then(function (chosen) { return addImported(chosen); });
|
|
491
1249
|
});
|
|
492
1250
|
}
|
|
493
1251
|
if (manual && manualRow) {
|
|
@@ -531,11 +1289,11 @@
|
|
|
531
1289
|
// was picked from is not recorded, so moving it later cannot break the pack — the operator's reason for the
|
|
532
1290
|
// change: 「万一文件夹移位了,也会出bug」. A directory an older build imported is still listed, and removable.
|
|
533
1291
|
importedMotions.imports.forEach(function (entry) {
|
|
534
|
-
list.appendChild(importedRow('
|
|
1292
|
+
list.appendChild(importedRow(t('importTag'), fill(t('motionImportedValue'), { name: entry.name, clips: entry.clips }), entry.source || entry.dir,
|
|
535
1293
|
function () { removeImportedMotion(entry.dir); }));
|
|
536
1294
|
});
|
|
537
1295
|
importedMotions.dirs.forEach(function (dir) {
|
|
538
|
-
list.appendChild(importedRow('
|
|
1296
|
+
list.appendChild(importedRow(t('legacyDir'), dir, dir, function () { removeLegacyMotionDir(dir); }, true));
|
|
539
1297
|
});
|
|
540
1298
|
if (!note) return;
|
|
541
1299
|
if (importedMotions.error) {
|
|
@@ -546,10 +1304,12 @@
|
|
|
546
1304
|
note.className = 'hint';
|
|
547
1305
|
var total = importedMotions.imports.length + importedMotions.dirs.length;
|
|
548
1306
|
note.textContent = total
|
|
549
|
-
? '
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
1307
|
+
? fill(t('importedMotionsNote'), {
|
|
1308
|
+
motions: importedMotions.imports.length,
|
|
1309
|
+
legacy: importedMotions.dirs.length ? fill(t('legacyDirsClause'), { dirs: importedMotions.dirs.length }) : '',
|
|
1310
|
+
clips: importedMotions.clips,
|
|
1311
|
+
})
|
|
1312
|
+
: t('onlyBundledMotions');
|
|
553
1313
|
}
|
|
554
1314
|
|
|
555
1315
|
function loadImportedMotions() {
|
|
@@ -560,12 +1320,12 @@
|
|
|
560
1320
|
importedMotions.dirs = body && Array.isArray(body.importedDirs) ? body.importedDirs : [];
|
|
561
1321
|
importedMotions.clips = body && Array.isArray(body.motions) ? body.motions.length : 0;
|
|
562
1322
|
importedMotions.catalogFile = body && body.catalogFile ? body.catalogFile : null;
|
|
563
|
-
importedMotions.error = body ? null : '
|
|
1323
|
+
importedMotions.error = body ? null : t('motionsRouteMissing');
|
|
564
1324
|
renderImportedMotions();
|
|
565
1325
|
return importedMotions;
|
|
566
1326
|
})
|
|
567
1327
|
.catch(function () {
|
|
568
|
-
importedMotions.error = '
|
|
1328
|
+
importedMotions.error = t('readMotionsFailed');
|
|
569
1329
|
renderImportedMotions();
|
|
570
1330
|
return importedMotions;
|
|
571
1331
|
});
|
|
@@ -581,7 +1341,7 @@
|
|
|
581
1341
|
}).then(function (response) {
|
|
582
1342
|
if (!response.ok) {
|
|
583
1343
|
return response.text().then(function (text) {
|
|
584
|
-
importedMotions.error = text || ('
|
|
1344
|
+
importedMotions.error = text || fill(t('importFailedHttp'), { status: response.status });
|
|
585
1345
|
renderImportedMotions();
|
|
586
1346
|
return null;
|
|
587
1347
|
});
|
|
@@ -601,13 +1361,13 @@
|
|
|
601
1361
|
importedMotions.clips = motions.length;
|
|
602
1362
|
}
|
|
603
1363
|
importedMotions.error = payload.persisted === false
|
|
604
|
-
? '
|
|
1364
|
+
? fill(t('persistFailedClause'), { reason: payload.persistError || t('unknownReason') })
|
|
605
1365
|
: null;
|
|
606
1366
|
renderImportedMotions();
|
|
607
1367
|
return motions ? null : loadMotions();
|
|
608
1368
|
});
|
|
609
1369
|
}).catch(function () {
|
|
610
|
-
importedMotions.error = '
|
|
1370
|
+
importedMotions.error = t('writeMotionsFailed');
|
|
611
1371
|
renderImportedMotions();
|
|
612
1372
|
return null;
|
|
613
1373
|
});
|
|
@@ -643,11 +1403,11 @@
|
|
|
643
1403
|
var picker = bridge && typeof bridge.pickMotionDir === 'function' ? bridge.pickMotionDir : null;
|
|
644
1404
|
if (!picker) {
|
|
645
1405
|
if (manualRow) manualRow.hidden = false;
|
|
646
|
-
importedMotions.error = '
|
|
1406
|
+
importedMotions.error = t('noFolderPicker');
|
|
647
1407
|
renderImportedMotions();
|
|
648
1408
|
return;
|
|
649
1409
|
}
|
|
650
|
-
Promise.resolve(picker()).then(function (chosen) { return addImportedMotion(chosen); });
|
|
1410
|
+
Promise.resolve(picker(language)).then(function (chosen) { return addImportedMotion(chosen); });
|
|
651
1411
|
});
|
|
652
1412
|
}
|
|
653
1413
|
if (manual && manualRow) {
|
|
@@ -740,25 +1500,26 @@
|
|
|
740
1500
|
settle();
|
|
741
1501
|
// Same rule as the catalog's failure: the cover lifts so the reason is readable rather than covered.
|
|
742
1502
|
setLoading(true);
|
|
743
|
-
setStatus('
|
|
1503
|
+
setStatus(t('infoFailed') + (error && error.message), 'warn');
|
|
744
1504
|
return null;
|
|
745
1505
|
});
|
|
746
1506
|
modelInfoInFlight = { id: id, promise: promise };
|
|
747
1507
|
return promise;
|
|
748
1508
|
}
|
|
749
1509
|
|
|
1510
|
+
/** The model file's own licence block as one line: the LABELS are ours and follow `t`, the facts are the file's. */
|
|
750
1511
|
function describeLicence(meta) {
|
|
751
|
-
if (!meta) return '
|
|
1512
|
+
if (!meta) return t('noLicence');
|
|
752
1513
|
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(' · ') : '
|
|
1514
|
+
if (meta.name || meta.title) parts.push(t('licenceName') + (meta.name || meta.title));
|
|
1515
|
+
if (meta.authors) parts.push(t('licenceAuthor') + (meta.authors || []).join('、'));
|
|
1516
|
+
if (meta.author) parts.push(t('licenceAuthor') + meta.author);
|
|
1517
|
+
if (meta.avatarPermission) parts.push(t('licencePermission') + meta.avatarPermission);
|
|
1518
|
+
if (meta.modification) parts.push(t('licenceModification') + meta.modification);
|
|
1519
|
+
if (meta.commercialUsage) parts.push(t('licenceCommercial') + meta.commercialUsage);
|
|
1520
|
+
if (meta.creditNotation) parts.push(t('licenceCredit') + meta.creditNotation);
|
|
1521
|
+
if (meta.licenseUrl) parts.push(t('licenceLicence') + meta.licenseUrl);
|
|
1522
|
+
return parts.length ? parts.join(' · ') : t('noLicence');
|
|
762
1523
|
}
|
|
763
1524
|
|
|
764
1525
|
function renderModel() {
|
|
@@ -773,19 +1534,19 @@
|
|
|
773
1534
|
* avatar was removed (2026-10-10) it was describing a model that cannot be shown. This says the true thing
|
|
774
1535
|
* instead: pick one, or the pet has nothing to draw.
|
|
775
1536
|
*/
|
|
776
|
-
spec.textContent = '
|
|
1537
|
+
spec.textContent = t('noModel');
|
|
777
1538
|
spec.className = 'pill off';
|
|
778
|
-
meta.textContent = '
|
|
1539
|
+
meta.textContent = t('noModelChosen');
|
|
779
1540
|
return;
|
|
780
1541
|
}
|
|
781
|
-
spec.textContent = modelInfo.supported ? (modelInfo.vrmSpec || 'VRM') : '
|
|
1542
|
+
spec.textContent = modelInfo.supported ? (modelInfo.vrmSpec || 'VRM') : t('notVrm');
|
|
782
1543
|
spec.className = 'pill ' + (modelInfo.supported ? 'on' : 'off');
|
|
783
1544
|
var counts = modelInfo.counts || {};
|
|
784
1545
|
var summary = [];
|
|
785
1546
|
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('
|
|
1547
|
+
summary.push((counts.meshes || 0) + t('meshUnit') + (counts.materials || 0) + t('materialUnit') + (counts.images || 0) + t('imageUnit'));
|
|
1548
|
+
if (typeof modelInfo.humanoid === 'object' && modelInfo.humanoid) summary.push(modelInfo.humanoid.count + t('humanoidUnit'));
|
|
1549
|
+
if (modelInfo.lookAt && modelInfo.lookAt.type) summary.push(t('lookAtUnit') + modelInfo.lookAt.type);
|
|
789
1550
|
meta.textContent = describeLicence(modelInfo.meta) + ' — ' + summary.join(' · ');
|
|
790
1551
|
}
|
|
791
1552
|
|
|
@@ -815,8 +1576,15 @@
|
|
|
815
1576
|
*/
|
|
816
1577
|
|
|
817
1578
|
|
|
818
|
-
/**
|
|
819
|
-
|
|
1579
|
+
/**
|
|
1580
|
+
* How a trigger row is titled. The KEYS below are UI words read through `t` at draw time; the emotion names in the
|
|
1581
|
+
* same table come from the model and are never translated.
|
|
1582
|
+
*/
|
|
1583
|
+
var TRIGGER_KEYS = { idle: 'triggerIdle', drag: 'triggerDrag', click: 'triggerClick', hold: 'triggerHold' };
|
|
1584
|
+
|
|
1585
|
+
function triggerLabel(trigger) {
|
|
1586
|
+
return t(TRIGGER_KEYS[trigger] || trigger);
|
|
1587
|
+
}
|
|
820
1588
|
|
|
821
1589
|
/**
|
|
822
1590
|
* The body parts a press can land on, as the settings page says them, for the DEFAULT regions.
|
|
@@ -891,7 +1659,7 @@
|
|
|
891
1659
|
core.EMOTION_TRIGGERS.forEach(function (trigger) {
|
|
892
1660
|
var tr = document.createElement('tr');
|
|
893
1661
|
var title = document.createElement('td');
|
|
894
|
-
title.textContent =
|
|
1662
|
+
title.textContent = triggerLabel(trigger);
|
|
895
1663
|
tr.appendChild(title);
|
|
896
1664
|
appendEmotionCells(tr, body, names, {
|
|
897
1665
|
key: trigger,
|
|
@@ -983,7 +1751,7 @@
|
|
|
983
1751
|
var on = document.createElement('input');
|
|
984
1752
|
on.type = 'checkbox';
|
|
985
1753
|
on.checked = row.enabled !== false;
|
|
986
|
-
on.title = '
|
|
1754
|
+
on.title = t('triggerToggleTitle');
|
|
987
1755
|
on.addEventListener('change', function () {
|
|
988
1756
|
spec.write({ enabled: on.checked });
|
|
989
1757
|
persist();
|
|
@@ -994,7 +1762,7 @@
|
|
|
994
1762
|
var listCell = document.createElement('td');
|
|
995
1763
|
if (!names.length) {
|
|
996
1764
|
listCell.className = 'note';
|
|
997
|
-
listCell.textContent = '
|
|
1765
|
+
listCell.textContent = t('noExpressions');
|
|
998
1766
|
}
|
|
999
1767
|
/**
|
|
1000
1768
|
* The expressions are a DROPDOWN multi-select, not a pile of checkboxes.
|
|
@@ -1022,12 +1790,12 @@
|
|
|
1022
1790
|
* the settings cannot represent.
|
|
1023
1791
|
*/
|
|
1024
1792
|
var summary = function () {
|
|
1025
|
-
return row.emotions.length ? row.emotions.join('、') : '
|
|
1793
|
+
return row.emotions.length ? row.emotions.join('、') : t('none');
|
|
1026
1794
|
};
|
|
1027
1795
|
trigger.textContent = summary();
|
|
1028
1796
|
trigger.title = row.emotions.length
|
|
1029
|
-
? '
|
|
1030
|
-
: '
|
|
1797
|
+
? fill(t('selectedExpressions'), { n: row.emotions.length })
|
|
1798
|
+
: t('noneRowTitle');
|
|
1031
1799
|
var menu = null;
|
|
1032
1800
|
var buildMenu = function () {
|
|
1033
1801
|
var panel = document.createElement('div');
|
|
@@ -1037,7 +1805,7 @@
|
|
|
1037
1805
|
var noneBox = document.createElement('input');
|
|
1038
1806
|
noneBox.type = 'checkbox';
|
|
1039
1807
|
noneBox.checked = row.emotions.length === 0;
|
|
1040
|
-
noneBox.title = '
|
|
1808
|
+
noneBox.title = t('noneBoxTitle');
|
|
1041
1809
|
noneBox.addEventListener('change', function () {
|
|
1042
1810
|
if (!noneBox.checked) { noneBox.checked = true; return; }
|
|
1043
1811
|
spec.write({ emotions: [] });
|
|
@@ -1045,7 +1813,7 @@
|
|
|
1045
1813
|
refresh();
|
|
1046
1814
|
});
|
|
1047
1815
|
none.appendChild(noneBox);
|
|
1048
|
-
none.appendChild(document.createTextNode('
|
|
1816
|
+
none.appendChild(document.createTextNode(t('none')));
|
|
1049
1817
|
panel.appendChild(none);
|
|
1050
1818
|
names.forEach(function (name) {
|
|
1051
1819
|
var label = document.createElement('label');
|
|
@@ -1121,10 +1889,10 @@
|
|
|
1121
1889
|
hold.max = '30';
|
|
1122
1890
|
hold.step = '0.5';
|
|
1123
1891
|
hold.value = String(row.holdSeconds);
|
|
1124
|
-
hold.title = '
|
|
1892
|
+
hold.title = t('holdSecondsTitle');
|
|
1125
1893
|
var unit = document.createElement('span');
|
|
1126
1894
|
unit.className = 'note';
|
|
1127
|
-
unit.textContent = '
|
|
1895
|
+
unit.textContent = ' ' + t('secondsUnit') + (row.holdSeconds === 0 ? t('heldSuffix') : '');
|
|
1128
1896
|
hold.addEventListener('change', function () {
|
|
1129
1897
|
spec.write({ holdSeconds: hold.value });
|
|
1130
1898
|
persist();
|
|
@@ -1132,7 +1900,7 @@
|
|
|
1132
1900
|
// same reason the expression menu updates a label instead of calling back into the renderer.
|
|
1133
1901
|
var live = spec.read ? spec.read() : row;
|
|
1134
1902
|
if (live) row = live;
|
|
1135
|
-
unit.textContent = '
|
|
1903
|
+
unit.textContent = ' ' + t('secondsUnit') + (row.holdSeconds === 0 ? t('heldSuffix') : '');
|
|
1136
1904
|
});
|
|
1137
1905
|
holdCell.appendChild(hold);
|
|
1138
1906
|
holdCell.appendChild(unit);
|
|
@@ -1166,7 +1934,7 @@
|
|
|
1166
1934
|
label.className = 'part-label';
|
|
1167
1935
|
label.value = item.label;
|
|
1168
1936
|
label.maxLength = core.PARTS_LIMITS.maxLabelLength;
|
|
1169
|
-
label.title = '
|
|
1937
|
+
label.title = t('partLabelTitle');
|
|
1170
1938
|
label.addEventListener('change', function () {
|
|
1171
1939
|
core.setPartItem(preferences, item.id, { label: label.value });
|
|
1172
1940
|
persist();
|
|
@@ -1176,7 +1944,7 @@
|
|
|
1176
1944
|
if (item.region) {
|
|
1177
1945
|
var regionNote = document.createElement('span');
|
|
1178
1946
|
regionNote.className = 'note';
|
|
1179
|
-
regionNote.textContent = '
|
|
1947
|
+
regionNote.textContent = ' ' + t('regionDefault') + ' ' + (PART_LABELS[item.region] || item.region);
|
|
1180
1948
|
labelCell.appendChild(regionNote);
|
|
1181
1949
|
}
|
|
1182
1950
|
tr.appendChild(labelCell);
|
|
@@ -1200,8 +1968,8 @@
|
|
|
1200
1968
|
var remove = document.createElement('button');
|
|
1201
1969
|
remove.type = 'button';
|
|
1202
1970
|
remove.className = 'part-remove';
|
|
1203
|
-
remove.textContent = '
|
|
1204
|
-
remove.title = '
|
|
1971
|
+
remove.textContent = t('partRemove');
|
|
1972
|
+
remove.title = t('partRemoveTitle');
|
|
1205
1973
|
remove.addEventListener('click', function () {
|
|
1206
1974
|
core.removePartItem(preferences, item.id);
|
|
1207
1975
|
persist();
|
|
@@ -1236,8 +2004,8 @@
|
|
|
1236
2004
|
*/
|
|
1237
2005
|
var partSource = core.cleanSpeech(preferences.speech).source;
|
|
1238
2006
|
lines.placeholder = partSource === 'ai'
|
|
1239
|
-
? '
|
|
1240
|
-
: '
|
|
2007
|
+
? t('partLinesAiPlaceholder')
|
|
2008
|
+
: t('partLinesPlaceholder');
|
|
1241
2009
|
lines.value = item.speech.lines.join('\n');
|
|
1242
2010
|
lines.addEventListener('change', function () {
|
|
1243
2011
|
core.setPartItem(preferences, item.id, { speech: { lines: lines.value } });
|
|
@@ -1260,7 +2028,7 @@
|
|
|
1260
2028
|
* ONE hint per source, for every part. Both shipped strings still ACT when a box is empty, and `builtinSpeech`
|
|
1261
2029
|
* in the published state still carries them — what is gone is only the announcement in the field.
|
|
1262
2030
|
*/
|
|
1263
|
-
prompt.placeholder = '
|
|
2031
|
+
prompt.placeholder = t('partPromptPlaceholder');
|
|
1264
2032
|
prompt.value = item.speech.prompt;
|
|
1265
2033
|
prompt.maxLength = core.SPEECH_LIMITS ? core.SPEECH_LIMITS.maxPrompt : 300;
|
|
1266
2034
|
prompt.addEventListener('change', function () {
|
|
@@ -1291,10 +2059,8 @@
|
|
|
1291
2059
|
var mode = document.createElement('button');
|
|
1292
2060
|
mode.type = 'button';
|
|
1293
2061
|
mode.className = 'chip-mode';
|
|
1294
|
-
mode.textContent = entry.exact ? '
|
|
1295
|
-
mode.title = entry.exact
|
|
1296
|
-
? '只匹配这个名字本身;点一下变成前缀匹配'
|
|
1297
|
-
: '匹配这个名字和它下面的一切(LiuHai.L.002 也算);点一下变成只匹配它本身';
|
|
2062
|
+
mode.textContent = entry.exact ? t('matchExact') : t('matchPrefixWord');
|
|
2063
|
+
mode.title = entry.exact ? t('matchExactTitle') : t('matchPrefixTitle');
|
|
1298
2064
|
mode.addEventListener('click', function () {
|
|
1299
2065
|
var next = item.match.map(function (candidate) {
|
|
1300
2066
|
return candidate.name === entry.name && candidate.exact === entry.exact
|
|
@@ -1309,7 +2075,7 @@
|
|
|
1309
2075
|
drop.type = 'button';
|
|
1310
2076
|
drop.className = 'chip-drop';
|
|
1311
2077
|
drop.textContent = '×';
|
|
1312
|
-
drop.title = '
|
|
2078
|
+
drop.title = t('chipDropTitle');
|
|
1313
2079
|
drop.addEventListener('click', function () {
|
|
1314
2080
|
var next = item.match.filter(function (candidate) {
|
|
1315
2081
|
return !(candidate.name === entry.name && candidate.exact === entry.exact);
|
|
@@ -1325,7 +2091,7 @@
|
|
|
1325
2091
|
var region = core.structureRegionOf(entry.name);
|
|
1326
2092
|
var hint = document.createElement('span');
|
|
1327
2093
|
hint.className = 'note';
|
|
1328
|
-
hint.textContent = ' ' + (region ? (PART_LABELS[region] || region) : '
|
|
2094
|
+
hint.textContent = ' ' + (region ? (PART_LABELS[region] || region) : t('unrecognised'));
|
|
1329
2095
|
chip.appendChild(hint);
|
|
1330
2096
|
cell.appendChild(chip);
|
|
1331
2097
|
});
|
|
@@ -1333,8 +2099,8 @@
|
|
|
1333
2099
|
var add = document.createElement('input');
|
|
1334
2100
|
add.type = 'text';
|
|
1335
2101
|
add.className = 'part-add-structure';
|
|
1336
|
-
add.placeholder = '
|
|
1337
|
-
add.title = '
|
|
2102
|
+
add.placeholder = t('addStructurePlaceholder');
|
|
2103
|
+
add.title = t('addStructureTitle');
|
|
1338
2104
|
var commit = function () {
|
|
1339
2105
|
var name = add.value.trim();
|
|
1340
2106
|
if (!name) return;
|
|
@@ -1358,8 +2124,8 @@
|
|
|
1358
2124
|
var browse = document.createElement('button');
|
|
1359
2125
|
browse.type = 'button';
|
|
1360
2126
|
browse.className = 'part-browse';
|
|
1361
|
-
browse.textContent = '
|
|
1362
|
-
browse.title = '
|
|
2127
|
+
browse.textContent = t('browse');
|
|
2128
|
+
browse.title = t('browseTitle');
|
|
1363
2129
|
browse.addEventListener('click', function () { openStructurePicker(item.id); });
|
|
1364
2130
|
cell.appendChild(browse);
|
|
1365
2131
|
}
|
|
@@ -1433,8 +2199,8 @@
|
|
|
1433
2199
|
name.textContent = entry.name;
|
|
1434
2200
|
var meta = document.createElement('span');
|
|
1435
2201
|
meta.className = 'note';
|
|
1436
|
-
meta.textContent = (region ? (PART_LABELS[region] || region) : '
|
|
1437
|
-
+ (owned ? '
|
|
2202
|
+
meta.textContent = (region ? (PART_LABELS[region] || region) : t('unrecognised')) + ' · ' + entry.kind
|
|
2203
|
+
+ (owned ? t('addedSuffix') : '');
|
|
1438
2204
|
row.appendChild(name);
|
|
1439
2205
|
row.appendChild(meta);
|
|
1440
2206
|
row.addEventListener('click', function () {
|
|
@@ -1446,7 +2212,7 @@
|
|
|
1446
2212
|
});
|
|
1447
2213
|
list.appendChild(row);
|
|
1448
2214
|
});
|
|
1449
|
-
if (count) count.textContent = structures.length
|
|
2215
|
+
if (count) count.textContent = fill(t('structuresCount'), { n: structures.length, total: modelStructures().length });
|
|
1450
2216
|
}
|
|
1451
2217
|
|
|
1452
2218
|
function bindBehaviourControls() {
|
|
@@ -1507,7 +2273,7 @@
|
|
|
1507
2273
|
if (element('part-max')) element('part-max').textContent = String(core.PARTS_LIMITS.maxItems);
|
|
1508
2274
|
if (add) {
|
|
1509
2275
|
add.addEventListener('click', function () {
|
|
1510
|
-
core.addPartItem(preferences, '
|
|
2276
|
+
core.addPartItem(preferences, t('newPart'));
|
|
1511
2277
|
persist();
|
|
1512
2278
|
renderParts();
|
|
1513
2279
|
});
|
|
@@ -1533,7 +2299,8 @@
|
|
|
1533
2299
|
renderParts();
|
|
1534
2300
|
}
|
|
1535
2301
|
|
|
1536
|
-
|
|
2302
|
+
/** The three spring fields' names, resolved through `t` every time a chain is drawn. */
|
|
2303
|
+
var SPRING_LABEL_KEYS = { stiffness: 'springStiffness', dragForce: 'springDrag', gravityPower: 'springGravity' };
|
|
1537
2304
|
|
|
1538
2305
|
/**
|
|
1539
2306
|
* One control block per spring chain of the configured model.
|
|
@@ -1590,9 +2357,9 @@
|
|
|
1590
2357
|
// where it can be changed.
|
|
1591
2358
|
var radius = Number(entry.radius);
|
|
1592
2359
|
text.textContent = [
|
|
1593
|
-
entry.groupName || ('
|
|
1594
|
-
entry.bone || '
|
|
1595
|
-
entry.kind === 'sphere' ? '
|
|
2360
|
+
entry.groupName || (t('colliderGroupPrefix') + entry.group),
|
|
2361
|
+
entry.bone || t('unnamedBone'),
|
|
2362
|
+
entry.kind === 'sphere' ? t('shapeSphere') : (entry.kind || t('shapeWord')),
|
|
1596
2363
|
].filter(Boolean).join(' · ');
|
|
1597
2364
|
/**
|
|
1598
2365
|
* …and the SIZE box (operator, 2026-10-14: 「增加修改弹簧骨碰撞体球尺寸的方法」).
|
|
@@ -1610,7 +2377,7 @@
|
|
|
1610
2377
|
size.max = String(range.max);
|
|
1611
2378
|
size.step = '0.005';
|
|
1612
2379
|
size.style.width = '86px';
|
|
1613
|
-
size.title = '
|
|
2380
|
+
size.title = t('colliderRadiusTitle');
|
|
1614
2381
|
var overrides = preferences.springColliderRadius && typeof preferences.springColliderRadius === 'object'
|
|
1615
2382
|
? preferences.springColliderRadius
|
|
1616
2383
|
: {};
|
|
@@ -1669,13 +2436,13 @@
|
|
|
1669
2436
|
|
|
1670
2437
|
if (!chains.length) {
|
|
1671
2438
|
if (note) {
|
|
1672
|
-
note.textContent = modelInfo ? '
|
|
2439
|
+
note.textContent = modelInfo ? t('noSprings') : '';
|
|
1673
2440
|
}
|
|
1674
2441
|
return;
|
|
1675
2442
|
}
|
|
1676
2443
|
if (note) {
|
|
1677
2444
|
var joints = chains.reduce(function (total, chain) { return total + (chain.bones || 0); }, 0);
|
|
1678
|
-
note.textContent = '
|
|
2445
|
+
note.textContent = fill(t('chainsTotal'), { chains: chains.length, joints: joints });
|
|
1679
2446
|
}
|
|
1680
2447
|
|
|
1681
2448
|
var modelId = modelIdInUse();
|
|
@@ -1700,7 +2467,7 @@
|
|
|
1700
2467
|
// the page state a live check reads.
|
|
1701
2468
|
details.setAttribute('data-grouped', String(chains.length));
|
|
1702
2469
|
var summary = document.createElement('summary');
|
|
1703
|
-
summary.textContent = '
|
|
2470
|
+
summary.textContent = fill(t('expandChains'), { n: chains.length });
|
|
1704
2471
|
details.appendChild(summary);
|
|
1705
2472
|
list.appendChild(details);
|
|
1706
2473
|
host = details;
|
|
@@ -1729,12 +2496,12 @@
|
|
|
1729
2496
|
kind: modelInfo && modelInfo.vrmSpec && String(modelInfo.vrmSpec).indexOf('1.0') === 0 ? 'vrm1' : 'vrm0',
|
|
1730
2497
|
}, Number.isFinite(chain.index) ? chain.index : chainIndex);
|
|
1731
2498
|
name.textContent = chain.comment || chain.name
|
|
1732
|
-
|| (chain.first ? chain.first + '
|
|
2499
|
+
|| (chain.first ? chain.first + t('unnamedChainSuffix') : t('unnamedChainPrefix') + (chainIndex + 1));
|
|
1733
2500
|
var meta = document.createElement('div');
|
|
1734
2501
|
meta.className = 'note';
|
|
1735
2502
|
meta.textContent = (chain.bones || 0) + ' joint'
|
|
1736
|
-
+ (chain.entries && chain.entries !== chain.bones ? '
|
|
1737
|
-
+ (chain.colliderGroups ? '
|
|
2503
|
+
+ (chain.entries && chain.entries !== chain.bones ? fill(t('entriesListed'), { n: chain.entries }) : '')
|
|
2504
|
+
+ (chain.colliderGroups ? fill(t('colliderGroupsNote'), { n: chain.colliderGroups }) : '');
|
|
1738
2505
|
head.appendChild(name);
|
|
1739
2506
|
head.appendChild(meta);
|
|
1740
2507
|
row.appendChild(head);
|
|
@@ -1743,7 +2510,7 @@
|
|
|
1743
2510
|
var cell = document.createElement('div');
|
|
1744
2511
|
cell.className = 'field';
|
|
1745
2512
|
var label = document.createElement('label');
|
|
1746
|
-
label.textContent =
|
|
2513
|
+
label.textContent = t(SPRING_LABEL_KEYS[field] || field);
|
|
1747
2514
|
var range = document.createElement('input');
|
|
1748
2515
|
range.type = 'range';
|
|
1749
2516
|
range.min = String(core.SPRING_LIMITS[field].min);
|
|
@@ -1772,8 +2539,8 @@
|
|
|
1772
2539
|
: (Number.isFinite(shipped) ? shipped : authored);
|
|
1773
2540
|
range.value = String(current);
|
|
1774
2541
|
number.value = String(Number(current).toFixed(2));
|
|
1775
|
-
number.title = '
|
|
1776
|
-
+ (Number.isFinite(shipped) ? '
|
|
2542
|
+
number.title = t('modelValuePrefix') + (Number.isFinite(authored) ? Number(authored).toFixed(3) : '?')
|
|
2543
|
+
+ (Number.isFinite(shipped) ? fill(t('packDefaultPrefix'), { n: Number(shipped).toFixed(3) }) : '');
|
|
1777
2544
|
|
|
1778
2545
|
var write = function (value) {
|
|
1779
2546
|
var stored = core.setSpringOverride(preferences, modelId, chainKey, field, value);
|
|
@@ -1781,28 +2548,28 @@
|
|
|
1781
2548
|
var next = stored === null ? (Number.isFinite(shipped) ? shipped : authored) : stored;
|
|
1782
2549
|
range.value = String(next);
|
|
1783
2550
|
number.value = String(Number(next).toFixed(2));
|
|
1784
|
-
cell.setAttribute('data-state', stored === null ? '
|
|
2551
|
+
cell.setAttribute('data-state', stored === null ? t('stateDefault') : t('stateChanged'));
|
|
1785
2552
|
};
|
|
1786
2553
|
range.addEventListener('input', function () { write(Number(range.value)); });
|
|
1787
2554
|
number.addEventListener('change', function () { write(Number(number.value)); });
|
|
1788
2555
|
|
|
1789
2556
|
var reset = document.createElement('button');
|
|
1790
2557
|
reset.className = 'ghost';
|
|
1791
|
-
reset.textContent = '
|
|
2558
|
+
reset.textContent = t('stateDefault');
|
|
1792
2559
|
reset.addEventListener('click', function () { write(null); });
|
|
1793
2560
|
|
|
1794
2561
|
cell.appendChild(label);
|
|
1795
2562
|
cell.appendChild(range);
|
|
1796
2563
|
cell.appendChild(number);
|
|
1797
2564
|
cell.appendChild(reset);
|
|
1798
|
-
cell.setAttribute('data-state', override && override[field] !== undefined ? '
|
|
2565
|
+
cell.setAttribute('data-state', override && override[field] !== undefined ? t('stateChanged') : t('stateDefault'));
|
|
1799
2566
|
row.appendChild(cell);
|
|
1800
2567
|
});
|
|
1801
2568
|
|
|
1802
2569
|
var actions = document.createElement('div');
|
|
1803
2570
|
var resetChain = document.createElement('button');
|
|
1804
2571
|
resetChain.className = 'ghost';
|
|
1805
|
-
resetChain.textContent = '
|
|
2572
|
+
resetChain.textContent = t('resetChain');
|
|
1806
2573
|
resetChain.addEventListener('click', function () {
|
|
1807
2574
|
core.clearSpringOverrides(preferences, modelId, chainKey);
|
|
1808
2575
|
persist();
|
|
@@ -1997,7 +2764,7 @@
|
|
|
1997
2764
|
var size = Number(entry.bytes);
|
|
1998
2765
|
var head = String(entry.name || entry.id) + ' (' + String(entry.format || '').toUpperCase();
|
|
1999
2766
|
var tail = size > 0 ? ', ' + Math.max(1, Math.round(size / 1024)) + ' KB' : '';
|
|
2000
|
-
return head + (entry.compressed ? '
|
|
2767
|
+
return head + (entry.compressed ? t('compressedTag') : '') + tail + ')';
|
|
2001
2768
|
}
|
|
2002
2769
|
|
|
2003
2770
|
/** The 循环/单次 marker for one clip IN ONE STATE, showing the mode in force rather than the file's word. */
|
|
@@ -2005,7 +2772,7 @@
|
|
|
2005
2772
|
var mode = document.createElement('span');
|
|
2006
2773
|
mode.className = 'mode';
|
|
2007
2774
|
mode.textContent = core.motionLoopForState(preferences, state, entry.id, entry.loop, motionClipDefaults) === false
|
|
2008
|
-
? '
|
|
2775
|
+
? t('once') : t('loop');
|
|
2009
2776
|
return mode;
|
|
2010
2777
|
}
|
|
2011
2778
|
|
|
@@ -2019,8 +2786,8 @@
|
|
|
2019
2786
|
function dancingOnceMarker() {
|
|
2020
2787
|
var mode = document.createElement('span');
|
|
2021
2788
|
mode.className = 'mode';
|
|
2022
|
-
mode.textContent = '
|
|
2023
|
-
mode.title = '
|
|
2789
|
+
mode.textContent = t('once');
|
|
2790
|
+
mode.title = t('dancingOnceTitle');
|
|
2024
2791
|
return mode;
|
|
2025
2792
|
}
|
|
2026
2793
|
|
|
@@ -2055,14 +2822,12 @@
|
|
|
2055
2822
|
function clipFaceSelect(state, entry, field) {
|
|
2056
2823
|
var select = document.createElement('select');
|
|
2057
2824
|
select.className = 'clip-face clip-' + field;
|
|
2058
|
-
select.title = field === 'expression'
|
|
2059
|
-
? '这支动作播放时显示的表情(覆盖情绪表的挑选)'
|
|
2060
|
-
: '这支动作播放时的口型(覆盖表情自带的口型)';
|
|
2825
|
+
select.title = field === 'expression' ? t('clipExpressionTitle') : t('clipMouthTitle');
|
|
2061
2826
|
var names = field === 'mouth' ? mouthNames() : expressionRows().map(function (row) { return row.name; });
|
|
2062
2827
|
var followValue = core.MOTION_FACE_FOLLOW || 'follow';
|
|
2063
2828
|
var follow = document.createElement('option');
|
|
2064
2829
|
follow.value = followValue;
|
|
2065
|
-
follow.textContent = field === 'mouth' ? '
|
|
2830
|
+
follow.textContent = field === 'mouth' ? t('followMouth') : t('followExpression');
|
|
2066
2831
|
select.appendChild(follow);
|
|
2067
2832
|
/**
|
|
2068
2833
|
* And 「默认」: no expression (or mouth) at all — the model's own initial face, overriding both the clip's own
|
|
@@ -2071,12 +2836,12 @@
|
|
|
2071
2836
|
*/
|
|
2072
2837
|
var blankFace = document.createElement('option');
|
|
2073
2838
|
blankFace.value = core.MOTION_FACE_DEFAULT || 'none';
|
|
2074
|
-
blankFace.textContent = field === 'mouth' ? '
|
|
2839
|
+
blankFace.textContent = field === 'mouth' ? t('blankMouth') : t('blankExpression');
|
|
2075
2840
|
select.appendChild(blankFace);
|
|
2076
2841
|
names.forEach(function (name) {
|
|
2077
2842
|
var option = document.createElement('option');
|
|
2078
2843
|
option.value = name;
|
|
2079
|
-
option.textContent = (field === 'mouth' ? '
|
|
2844
|
+
option.textContent = (field === 'mouth' ? t('mouthPrefix') : t('expressionPrefix')) + name;
|
|
2080
2845
|
select.appendChild(option);
|
|
2081
2846
|
});
|
|
2082
2847
|
// The RESOLVED answer: the profile's own field, else the template's, else 「跟随」 — which is what the pet is
|
|
@@ -2191,8 +2956,10 @@
|
|
|
2191
2956
|
var storedMotions = effective.stored;
|
|
2192
2957
|
if (count) {
|
|
2193
2958
|
var total = core.motionIdList(mapped).length;
|
|
2194
|
-
count.textContent =
|
|
2195
|
-
|
|
2959
|
+
count.textContent = fill(t('motionCount'), {
|
|
2960
|
+
offered: offered.length,
|
|
2961
|
+
active: total ? fill(t('motionCountActive'), { n: total }) : '',
|
|
2962
|
+
});
|
|
2196
2963
|
}
|
|
2197
2964
|
if (note) {
|
|
2198
2965
|
/**
|
|
@@ -2204,17 +2971,13 @@
|
|
|
2204
2971
|
* directories THEY imported. The count stays separate for the same reason.
|
|
2205
2972
|
*/
|
|
2206
2973
|
var bundled = offered.filter(function (entry) { return entry.source === 'bundled'; }).length;
|
|
2207
|
-
var summary = bundled
|
|
2208
|
-
? '其中 ' + bundled + ' 支随插件分发(assets/motions),不需要导入。'
|
|
2209
|
-
: '';
|
|
2974
|
+
var summary = bundled ? fill(t('bundledNote'), { n: bundled }) : '';
|
|
2210
2975
|
/**
|
|
2211
2976
|
* Compressed clips are counted too, because their size would otherwise read as a lie: a dance clip listed
|
|
2212
2977
|
* as 2.7 MB while it plays as an 8 MB animation looks like a broken number rather than like an archive.
|
|
2213
2978
|
*/
|
|
2214
2979
|
var packed = offered.filter(function (entry) { return entry.compressed === true; }).length;
|
|
2215
|
-
var packing = packed
|
|
2216
|
-
? '其中 ' + packed + ' 支是压缩存放(.br),播放时自动解压到内存,体积按压缩后的文件计。'
|
|
2217
|
-
: '';
|
|
2980
|
+
var packing = packed ? fill(t('packedNote'), { n: packed }) : '';
|
|
2218
2981
|
/**
|
|
2219
2982
|
* All-compressed reads differently from some-compressed.
|
|
2220
2983
|
*
|
|
@@ -2224,9 +2987,9 @@
|
|
|
2224
2987
|
*/
|
|
2225
2988
|
note.textContent = offered.length
|
|
2226
2989
|
? (packed === offered.length && packed > 0
|
|
2227
|
-
? summary + '
|
|
2990
|
+
? summary + fill(t('packedAllNote'), { n: packed })
|
|
2228
2991
|
: summary + packing)
|
|
2229
|
-
: '
|
|
2992
|
+
: t('noMotionFiles');
|
|
2230
2993
|
}
|
|
2231
2994
|
|
|
2232
2995
|
states.forEach(function (state) {
|
|
@@ -2261,8 +3024,8 @@
|
|
|
2261
3024
|
var stateCount = document.createElement('span');
|
|
2262
3025
|
stateCount.className = 'count';
|
|
2263
3026
|
stateCount.textContent = chosen.length
|
|
2264
|
-
? (configured ? '
|
|
2265
|
-
: (configured ? '
|
|
3027
|
+
? fill(t(configured ? 'motionStateCount' : 'motionStateDefaultCount'), { n: chosen.length })
|
|
3028
|
+
: (configured ? t('notPlaying') : t('proceduralIdle'));
|
|
2266
3029
|
summary.appendChild(name);
|
|
2267
3030
|
/**
|
|
2268
3031
|
* The folder this state's clips come from, in the summary line.
|
|
@@ -2276,7 +3039,7 @@
|
|
|
2276
3039
|
// Every folder the state draws from, because a state may name several and the operator edits exactly
|
|
2277
3040
|
// this line to change the list.
|
|
2278
3041
|
folderTag.textContent = folders.map(function (name) { return name + '/'; }).join(' ')
|
|
2279
|
-
+ ' · ' + inFolder.length
|
|
3042
|
+
+ ' · ' + fill(t('folderClips'), { n: inFolder.length });
|
|
2280
3043
|
summary.appendChild(folderTag);
|
|
2281
3044
|
}
|
|
2282
3045
|
summary.appendChild(stateCount);
|
|
@@ -2288,8 +3051,8 @@
|
|
|
2288
3051
|
*
|
|
2289
3052
|
* One row per status state, in the same box set as that state's motion, because the two questions belong to
|
|
2290
3053
|
* 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.
|
|
3054
|
+
* the defaults (`core.DEFAULT_STATUS_TEXT`, one table per language), so the boxes open already filled and can
|
|
3055
|
+
* simply be edited; clearing a box means this state says NOTHING, which is not the same as leaving it alone.
|
|
2293
3056
|
*
|
|
2294
3057
|
* NOT for the three pointer poses, and that asymmetry is deliberate rather than an oversight: 拖动中 /
|
|
2295
3058
|
* 抛掷中 / 落地中 carry a MOTION row since 2026-10-13 (「缺少拖动时模型动作的设置」), but the prompt above the
|
|
@@ -2314,7 +3077,7 @@
|
|
|
2314
3077
|
var every = document.createElement('div');
|
|
2315
3078
|
every.className = 'every';
|
|
2316
3079
|
var everyLabel = document.createElement('span');
|
|
2317
|
-
everyLabel.textContent = '
|
|
3080
|
+
everyLabel.textContent = t('everyLabel');
|
|
2318
3081
|
var everyMin = document.createElement('input');
|
|
2319
3082
|
everyMin.type = 'number';
|
|
2320
3083
|
everyMin.min = String(core.MOTION_EVERY_LIMITS.minSeconds);
|
|
@@ -2328,7 +3091,7 @@
|
|
|
2328
3091
|
everyMax.max = String(core.MOTION_EVERY_LIMITS.maxSeconds);
|
|
2329
3092
|
everyMax.step = '5';
|
|
2330
3093
|
var everyUnit = document.createElement('span');
|
|
2331
|
-
everyUnit.textContent = '
|
|
3094
|
+
everyUnit.textContent = t('secondsUnit');
|
|
2332
3095
|
var everyNote = document.createElement('span');
|
|
2333
3096
|
everyNote.className = 'note';
|
|
2334
3097
|
var oneShotCount = chosen.filter(function (id) {
|
|
@@ -2336,8 +3099,8 @@
|
|
|
2336
3099
|
return Boolean(entry) && core.motionLoopFor(preferences, id, entry.loop) === false;
|
|
2337
3100
|
}).length;
|
|
2338
3101
|
everyNote.textContent = oneShotCount
|
|
2339
|
-
? '
|
|
2340
|
-
: '
|
|
3102
|
+
? fill(t('oneShotHere'), { n: oneShotCount })
|
|
3103
|
+
: t('noOneShot');
|
|
2341
3104
|
everyMin.value = String(range ? range.min : 0);
|
|
2342
3105
|
everyMax.value = String(range ? range.max : 0);
|
|
2343
3106
|
var writeEvery = function () {
|
|
@@ -2380,8 +3143,9 @@
|
|
|
2380
3143
|
// Only when the folders really hold nothing: with the mapping read correctly this is the one honest
|
|
2381
3144
|
// case left (a state mapped to a folder the pack does not fill, like 工作中's `working/`), and it is
|
|
2382
3145
|
// the line that told the operator the list was empty while the clips were sitting in `idle/`.
|
|
2383
|
-
empty.textContent =
|
|
2384
|
-
+ '
|
|
3146
|
+
empty.textContent = fill(t('folderEmpty'), {
|
|
3147
|
+
folders: folders.map(function (name) { return name + '/'; }).join(' '),
|
|
3148
|
+
});
|
|
2385
3149
|
picks.appendChild(empty);
|
|
2386
3150
|
}
|
|
2387
3151
|
shown.forEach(function (entry) {
|
|
@@ -2431,8 +3195,8 @@
|
|
|
2431
3195
|
if (state !== 'dancing') {
|
|
2432
3196
|
var modeSelect = document.createElement('select');
|
|
2433
3197
|
modeSelect.className = 'clip-mode';
|
|
2434
|
-
modeSelect.title = '
|
|
2435
|
-
[['loop', '
|
|
3198
|
+
modeSelect.title = t('clipModeTitle');
|
|
3199
|
+
[['loop', t('loop')], ['once', t('once')]].forEach(function (pair) {
|
|
2436
3200
|
var option = document.createElement('option');
|
|
2437
3201
|
option.value = pair[0];
|
|
2438
3202
|
option.textContent = pair[1];
|
|
@@ -2465,9 +3229,9 @@
|
|
|
2465
3229
|
var speedLimits = core.MOTION_SPEED_LIMITS || { min: 1, max: 1000, default: 100 };
|
|
2466
3230
|
var speedLabel = document.createElement('label');
|
|
2467
3231
|
speedLabel.className = 'clip-speed';
|
|
2468
|
-
speedLabel.title = '
|
|
3232
|
+
speedLabel.title = t('clipSpeedTitle');
|
|
2469
3233
|
var speedText = document.createElement('span');
|
|
2470
|
-
speedText.textContent = '
|
|
3234
|
+
speedText.textContent = t('speed');
|
|
2471
3235
|
var speedBox = document.createElement('input');
|
|
2472
3236
|
speedBox.type = 'number';
|
|
2473
3237
|
speedBox.className = 'clip-speed-box';
|
|
@@ -2523,7 +3287,7 @@
|
|
|
2523
3287
|
*/
|
|
2524
3288
|
var gazeLabel = document.createElement('label');
|
|
2525
3289
|
gazeLabel.className = 'clip-gaze';
|
|
2526
|
-
gazeLabel.title = '
|
|
3290
|
+
gazeLabel.title = t('clipGazeTitle');
|
|
2527
3291
|
var gazeBox = document.createElement('input');
|
|
2528
3292
|
gazeBox.type = 'checkbox';
|
|
2529
3293
|
gazeBox.className = 'clip-gaze-box';
|
|
@@ -2531,7 +3295,7 @@
|
|
|
2531
3295
|
var gazeEntry = clipFaceEntry(state, entry.id);
|
|
2532
3296
|
gazeBox.checked = !(gazeEntry && gazeEntry.gaze === false);
|
|
2533
3297
|
var gazeText = document.createElement('span');
|
|
2534
|
-
gazeText.textContent = '
|
|
3298
|
+
gazeText.textContent = t('gazeWord');
|
|
2535
3299
|
gazeLabel.appendChild(gazeBox);
|
|
2536
3300
|
gazeLabel.appendChild(gazeText);
|
|
2537
3301
|
gazeBox.addEventListener('change', function () {
|
|
@@ -2594,7 +3358,7 @@
|
|
|
2594
3358
|
box.checked = true;
|
|
2595
3359
|
box.disabled = true;
|
|
2596
3360
|
var text = document.createElement('span');
|
|
2597
|
-
text.textContent = id + '
|
|
3361
|
+
text.textContent = id + t('notInCatalog');
|
|
2598
3362
|
label.appendChild(box);
|
|
2599
3363
|
label.appendChild(text);
|
|
2600
3364
|
picks.appendChild(label);
|
|
@@ -2614,17 +3378,16 @@
|
|
|
2614
3378
|
* counter rather than through a reload.
|
|
2615
3379
|
*/
|
|
2616
3380
|
function buildStatusRow(state) {
|
|
2617
|
-
|
|
2618
|
-
var
|
|
2619
|
-
|
|
2620
|
-
: '';
|
|
3381
|
+
// The shipped default in the RESOLVED language, so an English page opens with an English line in the box.
|
|
3382
|
+
var entry = core.statusEntryFor(preferences, state, language);
|
|
3383
|
+
var fallback = core.defaultStatusText ? core.defaultStatusText(state, language) : '';
|
|
2621
3384
|
var wrap = document.createElement('div');
|
|
2622
3385
|
wrap.className = 'status-line';
|
|
2623
3386
|
wrap.setAttribute('data-state', state);
|
|
2624
3387
|
|
|
2625
3388
|
var label = document.createElement('span');
|
|
2626
3389
|
label.className = 'status-label';
|
|
2627
|
-
label.textContent = '
|
|
3390
|
+
label.textContent = t('statusText');
|
|
2628
3391
|
wrap.appendChild(label);
|
|
2629
3392
|
|
|
2630
3393
|
var input = document.createElement('input');
|
|
@@ -2632,8 +3395,8 @@
|
|
|
2632
3395
|
input.className = 'status-text';
|
|
2633
3396
|
input.maxLength = core.STATUS_LIMITS ? core.STATUS_LIMITS.textLength : 40;
|
|
2634
3397
|
input.value = entry.text;
|
|
2635
|
-
input.placeholder = fallback ? fallback + '
|
|
2636
|
-
input.title = '
|
|
3398
|
+
input.placeholder = fallback ? fallback + t('defaultParen') : t('noTextParen');
|
|
3399
|
+
input.title = t('statusTextTitle');
|
|
2637
3400
|
input.addEventListener('change', function () {
|
|
2638
3401
|
preferences.status = core.setStatus(preferences, state, 'text', input.value);
|
|
2639
3402
|
persist();
|
|
@@ -2644,11 +3407,11 @@
|
|
|
2644
3407
|
|
|
2645
3408
|
var tag = document.createElement('select');
|
|
2646
3409
|
tag.className = 'status-tag';
|
|
2647
|
-
tag.title = '
|
|
3410
|
+
tag.title = t('statusTagTitle');
|
|
2648
3411
|
(core.STATUS_TAGS || ['']).forEach(function (value) {
|
|
2649
3412
|
var option = document.createElement('option');
|
|
2650
3413
|
option.value = value;
|
|
2651
|
-
option.textContent = value || '
|
|
3414
|
+
option.textContent = value || t('noTag');
|
|
2652
3415
|
tag.appendChild(option);
|
|
2653
3416
|
});
|
|
2654
3417
|
tag.value = entry.tag;
|
|
@@ -2662,8 +3425,8 @@
|
|
|
2662
3425
|
|
|
2663
3426
|
var animation = document.createElement('select');
|
|
2664
3427
|
animation.className = 'status-animation';
|
|
2665
|
-
animation.title = '
|
|
2666
|
-
[['none', '
|
|
3428
|
+
animation.title = t('statusAnimationTitle');
|
|
3429
|
+
[['none', t('animStatic')], ['shimmer', t('animShimmer')]].forEach(function (pair) {
|
|
2667
3430
|
var option = document.createElement('option');
|
|
2668
3431
|
option.value = pair[0];
|
|
2669
3432
|
option.textContent = pair[1];
|
|
@@ -2698,10 +3461,8 @@
|
|
|
2698
3461
|
var awaitingAnswer = state === 'waiting';
|
|
2699
3462
|
duration.disabled = awaitingAnswer;
|
|
2700
3463
|
duration.title = awaitingAnswer
|
|
2701
|
-
? '
|
|
2702
|
-
: (state === 'done' || state === 'error'
|
|
2703
|
-
? '至少显示多久;之后一直留到你切回这个会话(默认 4.2 秒是下限)'
|
|
2704
|
-
: '这个状态下文字显示多久;0 表示一直显示到状态结束');
|
|
3464
|
+
? t('waitingDurationTitle')
|
|
3465
|
+
: (state === 'done' || state === 'error' ? t('noticeDurationTitle') : t('normalDurationTitle'));
|
|
2705
3466
|
duration.addEventListener('change', function () {
|
|
2706
3467
|
var seconds = Math.max(0, Number(duration.value) || 0);
|
|
2707
3468
|
preferences.status = core.setStatus(preferences, state, 'seconds', seconds > 0 ? seconds : null);
|
|
@@ -2713,8 +3474,7 @@
|
|
|
2713
3474
|
|
|
2714
3475
|
var unit = document.createElement('span');
|
|
2715
3476
|
unit.className = 'note';
|
|
2716
|
-
unit.textContent = awaitingAnswer ? '
|
|
2717
|
-
: (untimed ? '秒(至少)' : '秒(0 = 始终)');
|
|
3477
|
+
unit.textContent = awaitingAnswer ? t('waitingUnit') : (untimed ? t('untimedUnit') : t('normalUnit'));
|
|
2718
3478
|
wrap.appendChild(unit);
|
|
2719
3479
|
|
|
2720
3480
|
return wrap;
|
|
@@ -2761,7 +3521,7 @@
|
|
|
2761
3521
|
var warn = element('motion-note');
|
|
2762
3522
|
if (warn) {
|
|
2763
3523
|
warn.className = 'hint warn';
|
|
2764
|
-
warn.textContent = '
|
|
3524
|
+
warn.textContent = fill(t('capWarning'), { cap: cap, state: stateName(state), kept: kept });
|
|
2765
3525
|
}
|
|
2766
3526
|
}
|
|
2767
3527
|
persist();
|
|
@@ -2778,13 +3538,14 @@
|
|
|
2778
3538
|
* which is why the mapping exists at all rather than showing the raw id.
|
|
2779
3539
|
*/
|
|
2780
3540
|
function stateName(state) {
|
|
2781
|
-
|
|
2782
|
-
resting: '
|
|
2783
|
-
done: '
|
|
2784
|
-
held: '
|
|
3541
|
+
var key = {
|
|
3542
|
+
resting: 'stateResting', working: 'stateWorking', waiting: 'stateWaiting',
|
|
3543
|
+
done: 'stateDone', error: 'stateError', offline: 'stateOffline',
|
|
3544
|
+
held: 'stateHeld', falling: 'stateFalling', landing: 'stateLanding',
|
|
2785
3545
|
// 跳舞 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]
|
|
3546
|
+
dancing: 'stateDancing',
|
|
3547
|
+
}[state];
|
|
3548
|
+
return key ? t(key) : state;
|
|
2788
3549
|
}
|
|
2789
3550
|
|
|
2790
3551
|
// ---------------------------------------------------------------------------
|
|
@@ -2809,6 +3570,19 @@
|
|
|
2809
3570
|
var windowSizeReset = element('pref-window-size-reset');
|
|
2810
3571
|
var viewReset = element('pref-view-reset');
|
|
2811
3572
|
var hidden = element('pref-hidden');
|
|
3573
|
+
/**
|
|
3574
|
+
* 界面语言 — the one control that changes the LANGUAGE every other label on the page is written in.
|
|
3575
|
+
*
|
|
3576
|
+
* The stored value is `auto` / `zh` / `en` and never the RESOLVED language (`core.LANGUAGE_SETTINGS`): a profile
|
|
3577
|
+
* that froze today's answer would keep a Chinese page for someone who later switched DSH to English, with no way
|
|
3578
|
+
* to tell that from a deliberate choice.
|
|
3579
|
+
*
|
|
3580
|
+
* The repaint is immediate and local as well as stored, because the patch comes back as a settings snapshot and
|
|
3581
|
+
* `adoptSnapshot` refuses to repaint while a control has focus — and this control HAS focus right after a change.
|
|
3582
|
+
* So the write goes out AND `applyLanguage` / `repaintLanguage` run here; the snapshot path then finds nothing
|
|
3583
|
+
* changed and does nothing.
|
|
3584
|
+
*/
|
|
3585
|
+
var languageSelect = element('pref-language');
|
|
2812
3586
|
/**
|
|
2813
3587
|
* 调试模式 — the one control whose preference is not a boolean but a value of 显示位置.
|
|
2814
3588
|
*
|
|
@@ -2865,6 +3639,18 @@
|
|
|
2865
3639
|
if (physics) physics.addEventListener('change', function () { write('physics', physics.checked); });
|
|
2866
3640
|
if (motion) motion.addEventListener('change', function () { write('motion', motion.checked); });
|
|
2867
3641
|
if (look) look.addEventListener('change', function () { write('lookAtPointer', look.checked); });
|
|
3642
|
+
if (languageSelect) {
|
|
3643
|
+
languageSelect.addEventListener('change', function () {
|
|
3644
|
+
write('language', core.cleanLanguage(languageSelect.value));
|
|
3645
|
+
/**
|
|
3646
|
+
* And the page follows AT ONCE rather than waiting for the snapshot to come back: the change is a label
|
|
3647
|
+
* change, not a control edit, so it is safe to repaint even though this select still has focus. The status
|
|
3648
|
+
* line is not part of `repaintLanguage` (it describes the PET), so it is re-read here too.
|
|
3649
|
+
*/
|
|
3650
|
+
if (applyLanguage()) repaintLanguage();
|
|
3651
|
+
refreshStatus();
|
|
3652
|
+
});
|
|
3653
|
+
}
|
|
2868
3654
|
if (size) {
|
|
2869
3655
|
size.addEventListener('input', function () {
|
|
2870
3656
|
write('size', Number(size.value));
|
|
@@ -2907,6 +3693,7 @@
|
|
|
2907
3693
|
if (motion) motion.checked = preferences.motion !== false;
|
|
2908
3694
|
if (look) look.checked = preferences.lookAtPointer !== false;
|
|
2909
3695
|
if (hidden) hidden.checked = preferences.hidden === true;
|
|
3696
|
+
if (languageSelect) languageSelect.value = core.cleanLanguage(preferences.language);
|
|
2910
3697
|
syncBoth();
|
|
2911
3698
|
syncSleep();
|
|
2912
3699
|
writeSize();
|
|
@@ -2932,6 +3719,14 @@
|
|
|
2932
3719
|
*/
|
|
2933
3720
|
var speechRows = {};
|
|
2934
3721
|
var speechLlm = null;
|
|
3722
|
+
/**
|
|
3723
|
+
* `showSpeech` and the 试一句 picker, kept so a language change can re-label both.
|
|
3724
|
+
*
|
|
3725
|
+
* The rows above are built ONCE (`renderSpeechStates`), so their labels and the picker's options cannot be
|
|
3726
|
+
* repainted by a re-render — see `relabelSpeechStates`, which is the one hop that reaches them.
|
|
3727
|
+
*/
|
|
3728
|
+
var speechShow = null;
|
|
3729
|
+
var speechTestState = null;
|
|
2935
3730
|
/**
|
|
2936
3731
|
* The provider whose MODEL LIST the last `/vrmpet3d/llm` answer carried, and the providers asked about.
|
|
2937
3732
|
*
|
|
@@ -2983,10 +3778,11 @@
|
|
|
2983
3778
|
|
|
2984
3779
|
/** One state's name as this page says it; 空闲 carries the idle remark, which has no state of its own. */
|
|
2985
3780
|
function speechStateName(state) {
|
|
2986
|
-
|
|
2987
|
-
resting: '
|
|
2988
|
-
done: '
|
|
2989
|
-
}[state]
|
|
3781
|
+
var key = {
|
|
3782
|
+
resting: 'speechStateResting', working: 'stateWorking', waiting: 'stateWaiting',
|
|
3783
|
+
done: 'stateDone', error: 'stateError', offline: 'stateOffline',
|
|
3784
|
+
}[state];
|
|
3785
|
+
return key ? t(key) : state;
|
|
2990
3786
|
}
|
|
2991
3787
|
|
|
2992
3788
|
/** Show or hide one of the AI rows; `hidden` alone would lose to `.row{display:flex}`. */
|
|
@@ -3034,19 +3830,13 @@
|
|
|
3034
3830
|
* never silence in any source. Until that day the shipped table was switched off by choosing 自定义/AI (2026-10-11) and
|
|
3035
3831
|
* these labels said 「不说话」 for one of them, which is exactly what a busy model made the whole pet do.
|
|
3036
3832
|
*/
|
|
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
|
-
};
|
|
3833
|
+
function emptyLinesLabel(source) {
|
|
3834
|
+
return source === 'ai' ? t('emptyLinesAi') : t('emptyLinesBuiltin');
|
|
3835
|
+
}
|
|
3836
|
+
|
|
3837
|
+
function emptyLinesPlaceholder(source) {
|
|
3838
|
+
return source === 'ai' ? t('emptyLinesPlaceholderAi') : t('emptyLinesPlaceholderBuiltin');
|
|
3839
|
+
}
|
|
3050
3840
|
|
|
3051
3841
|
/**
|
|
3052
3842
|
* Build the per-state boxes ONCE, then write into them.
|
|
@@ -3085,21 +3875,22 @@
|
|
|
3085
3875
|
body.className = 'speech-body';
|
|
3086
3876
|
var linesLabel = document.createElement('span');
|
|
3087
3877
|
linesLabel.className = 'speech-label';
|
|
3088
|
-
linesLabel.textContent = '
|
|
3878
|
+
linesLabel.textContent = t('linesLabel');
|
|
3089
3879
|
var lines = document.createElement('textarea');
|
|
3090
3880
|
lines.className = 'speech-lines';
|
|
3091
3881
|
lines.rows = 3;
|
|
3092
3882
|
lines.spellcheck = false;
|
|
3093
3883
|
// Written per SOURCE in `showSpeech`, which runs on every change and on every settings snapshot.
|
|
3094
|
-
lines.placeholder =
|
|
3884
|
+
lines.placeholder = t('emptyLinesPlaceholderBuiltin');
|
|
3095
3885
|
var promptLabel = document.createElement('span');
|
|
3096
3886
|
promptLabel.className = 'speech-label';
|
|
3097
|
-
promptLabel.textContent = '
|
|
3887
|
+
promptLabel.textContent = t('promptLabel');
|
|
3098
3888
|
var prompt = document.createElement('input');
|
|
3099
3889
|
prompt.type = 'text';
|
|
3100
3890
|
prompt.className = 'speech-prompt';
|
|
3101
3891
|
prompt.spellcheck = false;
|
|
3102
|
-
|
|
3892
|
+
// The instruction itself is core's own shipped prompt (Chinese, and sent to the model); only the fallback is ours.
|
|
3893
|
+
prompt.placeholder = (core.DEFAULT_SPEECH_PROMPTS || {})[state] || t('defaultSpeechPrompt');
|
|
3103
3894
|
body.appendChild(linesLabel);
|
|
3104
3895
|
body.appendChild(lines);
|
|
3105
3896
|
body.appendChild(promptLabel);
|
|
@@ -3107,8 +3898,41 @@
|
|
|
3107
3898
|
details.appendChild(body);
|
|
3108
3899
|
wrap.appendChild(details);
|
|
3109
3900
|
list.appendChild(wrap);
|
|
3110
|
-
speechRows[state] = {
|
|
3901
|
+
speechRows[state] = {
|
|
3902
|
+
details: details, name: name, count: count,
|
|
3903
|
+
linesLabel: linesLabel, promptLabel: promptLabel,
|
|
3904
|
+
lines: lines, prompt: prompt,
|
|
3905
|
+
};
|
|
3906
|
+
});
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3909
|
+
/**
|
|
3910
|
+
* The 碎碎念 rows' own labels after a language change — the ONE section `renderAll` cannot repaint.
|
|
3911
|
+
*
|
|
3912
|
+
* `renderSpeechStates` builds its rows ONCE, and that guard is load-bearing: rebuilding would close the boxes the
|
|
3913
|
+
* user has open and take the caret out of the one being typed in. So its two static labels and its six state names
|
|
3914
|
+
* would keep the old language for the rest of the session. Rather than forcing a rebuild, this hop rewrites the
|
|
3915
|
+
* handful of text nodes the row owns — no box's VALUE is touched — and then asks `showSpeech` to re-derive the
|
|
3916
|
+
* counts and the grey placeholders, which are written per SOURCE and were never cached.
|
|
3917
|
+
*
|
|
3918
|
+
* The 试一句 picker is here for the same reason: its options are built once, in `bindSpeechControls`.
|
|
3919
|
+
*/
|
|
3920
|
+
function relabelSpeechStates() {
|
|
3921
|
+
(core.SPEECH_STATES || []).forEach(function (state) {
|
|
3922
|
+
var row = speechRows[state];
|
|
3923
|
+
if (!row) return;
|
|
3924
|
+
if (row.name) row.name.textContent = speechStateName(state);
|
|
3925
|
+
if (row.linesLabel) row.linesLabel.textContent = t('linesLabel');
|
|
3926
|
+
if (row.promptLabel) row.promptLabel.textContent = t('promptLabel');
|
|
3927
|
+
// The instruction itself is core's shipped prompt (Chinese, and sent to the model); only the fallback is ours.
|
|
3928
|
+
if (row.prompt) row.prompt.placeholder = (core.DEFAULT_SPEECH_PROMPTS || {})[state] || t('defaultSpeechPrompt');
|
|
3111
3929
|
});
|
|
3930
|
+
if (speechTestState) {
|
|
3931
|
+
Array.prototype.forEach.call(speechTestState.options, function (option) {
|
|
3932
|
+
option.textContent = speechStateName(option.value);
|
|
3933
|
+
});
|
|
3934
|
+
}
|
|
3935
|
+
if (speechShow) speechShow();
|
|
3112
3936
|
}
|
|
3113
3937
|
|
|
3114
3938
|
function bindSpeechControls() {
|
|
@@ -3202,7 +4026,7 @@
|
|
|
3202
4026
|
* '用内置短语'了,应该是写'用内置AI生成提示词'").
|
|
3203
4027
|
*/
|
|
3204
4028
|
var emptyLabel = emptyLinesLabel(speech.source);
|
|
3205
|
-
var emptyPlaceholder =
|
|
4029
|
+
var emptyPlaceholder = emptyLinesPlaceholder(speech.source);
|
|
3206
4030
|
(core.SPEECH_STATES || []).forEach(function (state) {
|
|
3207
4031
|
var row = speechRows[state];
|
|
3208
4032
|
if (!row) return;
|
|
@@ -3211,7 +4035,7 @@
|
|
|
3211
4035
|
if (document.activeElement !== row.lines) row.lines.value = lines.join('\n');
|
|
3212
4036
|
if (document.activeElement !== row.prompt) row.prompt.value = speech.ai.prompts[state] || '';
|
|
3213
4037
|
if (row.lines.placeholder !== emptyPlaceholder) row.lines.placeholder = emptyPlaceholder;
|
|
3214
|
-
row.count.textContent = lines.length ? lines.length
|
|
4038
|
+
row.count.textContent = lines.length ? fill(t('lineCount'), { n: lines.length }) : emptyLabel;
|
|
3215
4039
|
});
|
|
3216
4040
|
if (provider) provider.value = speech.ai.provider;
|
|
3217
4041
|
var custom = speech.ai.provider === 'custom';
|
|
@@ -3301,15 +4125,15 @@
|
|
|
3301
4125
|
}
|
|
3302
4126
|
if (llmNote) {
|
|
3303
4127
|
var parts = [dsh.available
|
|
3304
|
-
? '
|
|
3305
|
-
: '
|
|
3306
|
-
if (dsh.available && !(dsh.providers || []).length) parts.push('
|
|
4128
|
+
? fill(t('llmAvailable'), { n: (dsh.providers || []).length })
|
|
4129
|
+
: t('llmUnavailable')];
|
|
4130
|
+
if (dsh.available && !(dsh.providers || []).length) parts.push(t('noProviders'));
|
|
3307
4131
|
if (dsh.available && dsh.provider && !(dsh.models || []).length) {
|
|
3308
|
-
parts.push('
|
|
4132
|
+
parts.push(t('providerNoModels'));
|
|
3309
4133
|
}
|
|
3310
|
-
if (dsh.error) parts.push('
|
|
3311
|
-
parts.push('
|
|
3312
|
-
llmNote.textContent = parts.join('
|
|
4134
|
+
if (dsh.error) parts.push(t('readFailedPrefix') + dsh.error);
|
|
4135
|
+
parts.push(t('customProviderNote'));
|
|
4136
|
+
llmNote.textContent = parts.join(t('noteJoin'));
|
|
3313
4137
|
}
|
|
3314
4138
|
showSpeech();
|
|
3315
4139
|
/**
|
|
@@ -3332,7 +4156,7 @@
|
|
|
3332
4156
|
publish();
|
|
3333
4157
|
})
|
|
3334
4158
|
.catch(function () {
|
|
3335
|
-
if (llmNote) llmNote.textContent = '
|
|
4159
|
+
if (llmNote) llmNote.textContent = t('llmNoAnswer');
|
|
3336
4160
|
});
|
|
3337
4161
|
};
|
|
3338
4162
|
|
|
@@ -3469,7 +4293,7 @@
|
|
|
3469
4293
|
if (testButton) {
|
|
3470
4294
|
testButton.addEventListener('click', function () {
|
|
3471
4295
|
var state = testState && (core.SPEECH_STATES || []).indexOf(testState.value) !== -1 ? testState.value : 'working';
|
|
3472
|
-
showTestResult('
|
|
4296
|
+
showTestResult(t('generating'), false);
|
|
3473
4297
|
// One at a time: the host refuses a second call while the first is running, and that refusal would
|
|
3474
4298
|
// otherwise land on top of the answer the user is waiting for.
|
|
3475
4299
|
testButton.disabled = true;
|
|
@@ -3478,25 +4302,31 @@
|
|
|
3478
4302
|
method: 'POST',
|
|
3479
4303
|
headers: { 'content-type': 'application/json' },
|
|
3480
4304
|
credentials: 'omit',
|
|
3481
|
-
|
|
4305
|
+
// The HOST answers in this language, so the test line reads like the pet the user is configuring.
|
|
4306
|
+
body: JSON.stringify({ state: state, language: language }),
|
|
3482
4307
|
}).then(function (response) { return response.json(); })
|
|
3483
4308
|
.then(function (body) {
|
|
3484
4309
|
settle();
|
|
3485
4310
|
if (body && body.text) {
|
|
3486
|
-
showTestResult('
|
|
4311
|
+
showTestResult(t('quoteOpen') + body.text + t('quoteClose')
|
|
4312
|
+
+ (Number.isFinite(body.ms) ? fill(t('msParen'), { ms: body.ms }) : ''), false);
|
|
3487
4313
|
} else {
|
|
3488
|
-
showTestResult('
|
|
4314
|
+
showTestResult(t('failedPrefix') + ((body && body.error) || t('hostNoAnswer')), true);
|
|
3489
4315
|
}
|
|
3490
4316
|
})
|
|
3491
4317
|
.catch(function (error) {
|
|
3492
4318
|
settle();
|
|
3493
|
-
showTestResult('
|
|
4319
|
+
showTestResult(t('failedPrefix') + (error && error.message), true);
|
|
3494
4320
|
});
|
|
3495
4321
|
});
|
|
3496
4322
|
}
|
|
3497
4323
|
|
|
3498
4324
|
renderSpeechStates();
|
|
3499
4325
|
showSpeech();
|
|
4326
|
+
// The two handles a language change needs: `showSpeech` re-derives the counts and the grey placeholders from
|
|
4327
|
+
// the source, and the 试一句 picker's options are built once and carry state names.
|
|
4328
|
+
speechShow = showSpeech;
|
|
4329
|
+
speechTestState = testState;
|
|
3500
4330
|
loadLlm(core.cleanSpeech(preferences.speech).ai.dshProvider || '');
|
|
3501
4331
|
}
|
|
3502
4332
|
|
|
@@ -3537,7 +4367,7 @@
|
|
|
3537
4367
|
};
|
|
3538
4368
|
var showCurve = function () {
|
|
3539
4369
|
var slope = Number(preferences.gazeCurve);
|
|
3540
|
-
if (curveValue) curveValue.textContent = slope.toFixed(2) + (slope >= 0.995 ? '
|
|
4370
|
+
if (curveValue) curveValue.textContent = slope.toFixed(2) + (slope >= 0.995 ? t('straightLine') : '');
|
|
3541
4371
|
if (curve) curve.value = String(Math.round(slope * 100));
|
|
3542
4372
|
};
|
|
3543
4373
|
var showSpans = function () {
|
|
@@ -3560,7 +4390,7 @@
|
|
|
3560
4390
|
var radius = Number(preferences.windRadius);
|
|
3561
4391
|
if (windRadiusValue) {
|
|
3562
4392
|
windRadiusValue.textContent = Math.round((Number.isFinite(radius) ? radius : 0) * 100) + '%'
|
|
3563
|
-
+ (radius <= 0 ? '
|
|
4393
|
+
+ (radius <= 0 ? t('globalRadius') : '');
|
|
3564
4394
|
}
|
|
3565
4395
|
if (windRadius) windRadius.value = String(Math.round((Number.isFinite(radius) ? radius : 0) * 100));
|
|
3566
4396
|
if (windDecay) windDecay.value = String(Number(preferences.windDecay));
|
|
@@ -3682,7 +4512,7 @@
|
|
|
3682
4512
|
.then(function (response) { return response.ok ? response.json() : null; })
|
|
3683
4513
|
.then(function (status) {
|
|
3684
4514
|
if (!status) {
|
|
3685
|
-
setStatus('
|
|
4515
|
+
setStatus(t('floatingUnavailable'));
|
|
3686
4516
|
return null;
|
|
3687
4517
|
}
|
|
3688
4518
|
var page = status.page || {};
|
|
@@ -3690,25 +4520,27 @@
|
|
|
3690
4520
|
// Say what each surface is showing, because that is what a spring override is keyed by: a user
|
|
3691
4521
|
// configuring a model nobody is showing would otherwise see sliders that do nothing.
|
|
3692
4522
|
var parts = [];
|
|
3693
|
-
parts.push('
|
|
3694
|
-
parts.push('
|
|
4523
|
+
parts.push(fill(t('statusModel'), { model: modelIdInUse() }));
|
|
4524
|
+
parts.push(fill(t('statusWindow'), { state: status.running ? t('windowRunning') : t('windowOff') }));
|
|
3695
4525
|
setStatus(parts.join(' · '));
|
|
3696
4526
|
var shown = status.running
|
|
3697
|
-
?
|
|
3698
|
-
|
|
3699
|
-
|
|
4527
|
+
? (page.ready
|
|
4528
|
+
? fill(t('windowRenderedReady'), { model: status.modelId || '?' })
|
|
4529
|
+
: t('windowRenderedNotReady'))
|
|
4530
|
+
: t('windowNotOpen');
|
|
4531
|
+
setStatus(fill(t('currentModelLine'), { model: modelIdInUse(), window: shown }));
|
|
3700
4532
|
// What "自动" resolves to, for the surface the user is most likely looking at. The host reports the
|
|
3701
4533
|
// work area, and the floating pet's horizontal full deflection IS that width.
|
|
3702
4534
|
var auto = element('pref-gaze-span-auto');
|
|
3703
4535
|
if (auto) {
|
|
3704
4536
|
var work = status.display && status.display.workArea ? Math.round(status.display.workArea.width) : 0;
|
|
3705
|
-
auto.textContent = work > 0 ? '
|
|
4537
|
+
auto.textContent = work > 0 ? fill(t('spanNow'), { px: work }) : t('noScreenSize');
|
|
3706
4538
|
}
|
|
3707
4539
|
showAppliedGaze(status);
|
|
3708
4540
|
return status;
|
|
3709
4541
|
})
|
|
3710
4542
|
.catch(function () {
|
|
3711
|
-
setStatus('
|
|
4543
|
+
setStatus(t('statusReadFailed'), 'warn');
|
|
3712
4544
|
return null;
|
|
3713
4545
|
});
|
|
3714
4546
|
}
|
|
@@ -3739,19 +4571,22 @@
|
|
|
3739
4571
|
}
|
|
3740
4572
|
if (!host) return;
|
|
3741
4573
|
if (!status || !status.running) {
|
|
3742
|
-
host.textContent = '
|
|
4574
|
+
host.textContent = t('gazeNotRunning');
|
|
3743
4575
|
return;
|
|
3744
4576
|
}
|
|
3745
4577
|
if (!live.appliedGaze) {
|
|
3746
4578
|
// A window from before this diagnostic existed: say so rather than showing a stale number.
|
|
3747
|
-
host.textContent = '
|
|
4579
|
+
host.textContent = t('gazeNoReport');
|
|
3748
4580
|
return;
|
|
3749
4581
|
}
|
|
3750
4582
|
var aim = live.appliedGaze;
|
|
3751
|
-
host.textContent = '
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
4583
|
+
host.textContent = fill(t('gazeApplied'), {
|
|
4584
|
+
curve: aim.curve.toFixed(2),
|
|
4585
|
+
spanX: aim.spanX,
|
|
4586
|
+
spanY: aim.spanY,
|
|
4587
|
+
aimX: aim.aim.x.toFixed(2),
|
|
4588
|
+
aimY: aim.aim.y.toFixed(2),
|
|
4589
|
+
});
|
|
3755
4590
|
}
|
|
3756
4591
|
|
|
3757
4592
|
function bindModelSelect() {
|
|
@@ -3979,14 +4814,15 @@
|
|
|
3979
4814
|
* says whether the pet would say it right now (operator, 2026-10-12: "如果设置了AI碎碎念,就停止输出内置的碎碎念").
|
|
3980
4815
|
*
|
|
3981
4816
|
* 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
|
-
*
|
|
4817
|
+
* is the click pool and `holdLines` the long-press one, both in the RESOLVED language, because a readout of
|
|
4818
|
+
* what a line would say has to be in the language the page is drawing in. It reads them with `tableAllowed`
|
|
4819
|
+
* TRUE for the reason above: a readout is not a source.
|
|
3984
4820
|
*/
|
|
3985
4821
|
builtinSpeech: items.filter(function (item) {
|
|
3986
4822
|
return !item.speech.lines.length && !item.speech.prompt;
|
|
3987
4823
|
}).map(function (item) {
|
|
3988
|
-
var builtinLines = core.partSpeechLines ? core.partSpeechLines(item, true,
|
|
3989
|
-
var builtinHold = core.partSpeechLines ? core.partSpeechLines(item, true,
|
|
4824
|
+
var builtinLines = core.partSpeechLines ? core.partSpeechLines(item, true, language, 'click') : [];
|
|
4825
|
+
var builtinHold = core.partSpeechLines ? core.partSpeechLines(item, true, language, 'hold') : [];
|
|
3990
4826
|
var builtinPrompt = core.partSpeechPrompt ? core.partSpeechPrompt(item) : '';
|
|
3991
4827
|
if (!builtinLines.length && !builtinHold.length && !builtinPrompt) return null;
|
|
3992
4828
|
return {
|
|
@@ -4127,7 +4963,7 @@
|
|
|
4127
4963
|
/**
|
|
4128
4964
|
* What an EMPTY box currently says, and in whose words.
|
|
4129
4965
|
*
|
|
4130
|
-
* Published because it is a LABEL that has to follow the source (`
|
|
4966
|
+
* Published because it is a LABEL that has to follow the source (`emptyLinesLabel`), and a label that drifts
|
|
4131
4967
|
* back to 「用内置短语」 while the source is AI is exactly the confusion the operator reported: "AI碎碎念开启后,
|
|
4132
4968
|
* 面板里的各种状态后面就不应该写'用内置短语'了,应该是写'用内置AI生成提示词'."
|
|
4133
4969
|
*/
|
|
@@ -4405,14 +5241,14 @@
|
|
|
4405
5241
|
statusTexts: (function () {
|
|
4406
5242
|
var out = {};
|
|
4407
5243
|
(core.STATUS_STATES || []).forEach(function (state) {
|
|
4408
|
-
out[state] = core.statusLineFor(preferences, state);
|
|
5244
|
+
out[state] = core.statusLineFor(preferences, state, language);
|
|
4409
5245
|
});
|
|
4410
5246
|
return out;
|
|
4411
5247
|
})(),
|
|
4412
5248
|
statusAnimations: (function () {
|
|
4413
5249
|
var out = {};
|
|
4414
5250
|
(core.STATUS_STATES || []).forEach(function (state) {
|
|
4415
|
-
out[state] = core.statusEntryFor(preferences, state).animation;
|
|
5251
|
+
out[state] = core.statusEntryFor(preferences, state, language).animation;
|
|
4416
5252
|
});
|
|
4417
5253
|
return out;
|
|
4418
5254
|
})(),
|
|
@@ -4444,6 +5280,16 @@
|
|
|
4444
5280
|
};
|
|
4445
5281
|
}
|
|
4446
5282
|
|
|
5283
|
+
/**
|
|
5284
|
+
* THE LANGUAGE, before anything draws.
|
|
5285
|
+
*
|
|
5286
|
+
* `tagFixedRows` first (the two rows whose attributes cannot live in the markup — see it), then one
|
|
5287
|
+
* `applyLanguage`, because every renderer below reads `t()` and the static labels are written by the sweep in
|
|
5288
|
+
* there. A change made later comes back through the same pair — see the 界面语言 control and `adoptSnapshot`.
|
|
5289
|
+
*/
|
|
5290
|
+
tagFixedRows();
|
|
5291
|
+
applyLanguage();
|
|
5292
|
+
|
|
4447
5293
|
bindBehaviourControls();
|
|
4448
5294
|
bindParts();
|
|
4449
5295
|
bindPreferenceControls();
|
|
@@ -4465,7 +5311,7 @@
|
|
|
4465
5311
|
if (!button) return;
|
|
4466
5312
|
button.addEventListener('click', function () {
|
|
4467
5313
|
var fallback = function (reason) {
|
|
4468
|
-
if (note) note.textContent = reason || '
|
|
5314
|
+
if (note) note.textContent = reason || t('setupOpenedTab');
|
|
4469
5315
|
try { globalThis.open('/vrmpet3d/setup', '_blank'); } catch (error) { /* the user can open it by hand */ }
|
|
4470
5316
|
};
|
|
4471
5317
|
fetch('/vrmpet3d/desktop', {
|
|
@@ -4474,9 +5320,9 @@
|
|
|
4474
5320
|
headers: { 'content-type': 'application/json' },
|
|
4475
5321
|
body: JSON.stringify({ action: 'setup' }),
|
|
4476
5322
|
}).then(function (response) {
|
|
4477
|
-
if (!response.ok) { fallback('
|
|
4478
|
-
if (note) note.textContent = '
|
|
4479
|
-
}).catch(function () { fallback('
|
|
5323
|
+
if (!response.ok) { fallback(t('setupFallbackTab')); return; }
|
|
5324
|
+
if (note) note.textContent = t('setupOpenedWindow');
|
|
5325
|
+
}).catch(function () { fallback(t('setupNoHost')); });
|
|
4480
5326
|
});
|
|
4481
5327
|
})();
|
|
4482
5328
|
bindModelSelect();
|
|
@@ -4570,8 +5416,18 @@
|
|
|
4570
5416
|
pendingLlmChoice = null;
|
|
4571
5417
|
writeLlmChoice(held);
|
|
4572
5418
|
}
|
|
4573
|
-
|
|
4574
|
-
|
|
5419
|
+
/**
|
|
5420
|
+
* THE LANGUAGE, re-resolved on every snapshot — before either of the two render decisions below.
|
|
5421
|
+
*
|
|
5422
|
+
* It is asked FIRST because a change made in another window (or by this one, which then polls) has to reach the
|
|
5423
|
+
* screen even while a control here has focus: `busyWithAControl` exists to keep a repaint from yanking a slider
|
|
5424
|
+
* out from under a drag, and a label change cannot do that. So a language that really moved repaints through
|
|
5425
|
+
* `repaintLanguage` (which includes the three sections `renderAll` owns plus the ones that live on their own
|
|
5426
|
+
* loaders), and the ordinary poll still does nothing but `renderAll` when nothing changed.
|
|
5427
|
+
*/
|
|
5428
|
+
var languageMoved = applyLanguage();
|
|
5429
|
+
if (busyWithAControl() && !languageMoved) return;
|
|
5430
|
+
if (languageMoved) repaintLanguage(); else renderAll();
|
|
4575
5431
|
refreshStatus();
|
|
4576
5432
|
publish();
|
|
4577
5433
|
}
|
|
@@ -4619,6 +5475,41 @@
|
|
|
4619
5475
|
}
|
|
4620
5476
|
}
|
|
4621
5477
|
|
|
5478
|
+
/**
|
|
5479
|
+
* The two rows whose MARKUP may not carry their `data-i18n` attributes in the file, so they get them here.
|
|
5480
|
+
*
|
|
5481
|
+
* 调试模式 is stripped from release builds by `scripts/make-release-pages.mjs`, which matches the whole row —
|
|
5482
|
+
* comment included — as ONE literal block: a new attribute in the source would make that transform refuse to run.
|
|
5483
|
+
*
|
|
5484
|
+
* 碎碎念's 免责说明 is read by `verify-desktop.mjs` by matching its opening tag with the class and the id in that
|
|
5485
|
+
* exact order and spelling, and the copy is then measured against the guide's longer one — so neither an extra
|
|
5486
|
+
* attribute nor a wrapping `<span>` can go in the file.
|
|
5487
|
+
*
|
|
5488
|
+
* Both are still translated, exactly like every other row: the attributes go on BEFORE the first `applyLanguage`
|
|
5489
|
+
* (see the boot below), and every later pass treats them as markup it always had. On a release build the 调试模式
|
|
5490
|
+
* lookups simply find nothing, which is what the transform intends.
|
|
5491
|
+
*/
|
|
5492
|
+
function tagFixedRows() {
|
|
5493
|
+
var label = document.querySelector('label[for="pref-both"]');
|
|
5494
|
+
if (label) label.setAttribute('data-i18n', 'debugMode');
|
|
5495
|
+
var box = element('pref-both');
|
|
5496
|
+
var wrap = box ? box.parentNode : null;
|
|
5497
|
+
if (wrap) {
|
|
5498
|
+
// The checkbox's own label is a bare text node beside the input; it becomes a span so it can be marked.
|
|
5499
|
+
for (var i = wrap.childNodes.length - 1; i >= 0; i -= 1) {
|
|
5500
|
+
var node = wrap.childNodes[i];
|
|
5501
|
+
if (node.nodeType !== 3 || !node.nodeValue.trim()) continue;
|
|
5502
|
+
var span = document.createElement('span');
|
|
5503
|
+
span.setAttribute('data-i18n', 'debugModeBox');
|
|
5504
|
+
span.textContent = node.nodeValue.trim();
|
|
5505
|
+
wrap.replaceChild(span, node);
|
|
5506
|
+
break;
|
|
5507
|
+
}
|
|
5508
|
+
}
|
|
5509
|
+
var disclaimer = element('speech-disclaimer');
|
|
5510
|
+
if (disclaimer) disclaimer.setAttribute('data-i18n-html', 'speechDisclaimer');
|
|
5511
|
+
}
|
|
5512
|
+
|
|
4622
5513
|
/**
|
|
4623
5514
|
* TWO PAGES, TWO ROUTES — the split, second attempt (operator, 2026-10-11, and again the same day).
|
|
4624
5515
|
*
|
|
@@ -4657,7 +5548,7 @@
|
|
|
4657
5548
|
* control silently do nothing on such a host — a worse failure than the one it guards.
|
|
4658
5549
|
*/
|
|
4659
5550
|
hasSnapshot = true;
|
|
4660
|
-
setStatus('
|
|
5551
|
+
setStatus(t('settingsUnsupported'), 'warn');
|
|
4661
5552
|
},
|
|
4662
5553
|
});
|
|
4663
5554
|
} else {
|