dsh-plugin-vrmpet3d 0.1.0 → 0.1.2

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