modelstage 0.0.0-stage → 0.5.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +274 -2
- package/THIRD_PARTY_NOTICES.md +90 -0
- package/bin/modelstage.mjs +137 -0
- package/config/diag-upload.example.json +7 -0
- package/config/models/live2d.assetbundls.json +1 -0
- package/config/models/live2d.azurlane.json +1 -0
- package/config/models/live2d.azurlane_bundle.json +1 -0
- package/config/models/live2d.azurlane_spine.json +1 -0
- package/config/models/live2d.bd2.json +1 -0
- package/config/models/live2d.bluearchive.json +1 -0
- package/config/models/live2d.live2d.json +1 -0
- package/config/models/live2d.nikke.json +1 -0
- package/config/models/live2d.nikke_2.json +1 -0
- package/config/models/live2d.npc.json +1 -0
- package/config/models/spine.assetbundls.json +1 -0
- package/config/models/spine.azurlane.json +1 -0
- package/config/models/spine.azurlane_bundle.json +1 -0
- package/config/models/spine.azurlane_spine.json +1 -0
- package/config/models/spine.bd2.json +1 -0
- package/config/models/spine.bluearchive.json +1 -0
- package/config/models/spine.live2d.json +1 -0
- package/config/models/spine.nikke.json +1 -0
- package/config/models/spine.nikke_2.json +1 -0
- package/config/models/spine.npc.json +1 -0
- package/config/names/NIKKE.json +1058 -0
- package/config/names/README.md +47 -0
- package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
- package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
- package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
- package/config/plugins.json +13 -0
- package/config/resources.example.json +40 -0
- package/package.json +63 -4
- package/plugins/unity-live2d-bundle/README.md +98 -0
- package/plugins/unity-live2d-bundle/index.js +891 -0
- package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
- package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
- package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
- package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
- package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
- package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
- package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
- package/plugins/unity-live2d-bundle/lib/png.js +424 -0
- package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
- package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
- package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-live2d-bundle/plugin.json +81 -0
- package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
- package/plugins/unity-spine-bundle/index.js +535 -0
- package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
- package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
- package/plugins/unity-spine-bundle/lib/errors.js +79 -0
- package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
- package/plugins/unity-spine-bundle/lib/extract.js +242 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-spine-bundle/lib/png.js +424 -0
- package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
- package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
- package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-spine-bundle/plugin.json +75 -0
- package/src/server/README.md +69 -0
- package/src/server/assets/browse.mjs +56 -0
- package/src/server/assets/bundle-probe.mjs +94 -0
- package/src/server/assets/probe.mjs +184 -0
- package/src/server/core/config.mjs +173 -0
- package/src/server/core/env-num.mjs +60 -0
- package/src/server/core/env.mjs +49 -0
- package/src/server/core/fs-portable.mjs +37 -0
- package/src/server/core/log.mjs +107 -0
- package/src/server/core/paths.mjs +44 -0
- package/src/server/data/catalog.mjs +1091 -0
- package/src/server/data/entry-states.mjs +499 -0
- package/src/server/data/live2d.mjs +272 -0
- package/src/server/data/manifest-cache.mjs +58 -0
- package/src/server/data/manifest-stamp.mjs +135 -0
- package/src/server/data/model-meta.mjs +114 -0
- package/src/server/data/names.mjs +106 -0
- package/src/server/data/shape-probe-worker.mjs +33 -0
- package/src/server/data/shape-probe.mjs +626 -0
- package/src/server/data/spine.mjs +336 -0
- package/src/server/data/tree.mjs +126 -0
- package/src/server/http/atomic-json.mjs +145 -0
- package/src/server/http/background-solids.mjs +270 -0
- package/src/server/http/backgrounds.mjs +388 -0
- package/src/server/http/diag-upload.mjs +264 -0
- package/src/server/http/diag.mjs +173 -0
- package/src/server/http/ready.mjs +167 -0
- package/src/server/http/static.mjs +312 -0
- package/src/server/http/write.mjs +654 -0
- package/src/server/index.mjs +114 -0
- package/src/server/plugins/builtin-providers.json +30 -0
- package/src/server/plugins/registry.mjs +650 -0
- package/src/server/plugins/slot-catalog.mjs +286 -0
- package/src/server/routes/background-solids.mjs +56 -0
- package/src/server/routes/backgrounds.mjs +77 -0
- package/src/server/routes/config.mjs +38 -0
- package/src/server/routes/data.mjs +313 -0
- package/src/server/routes/diag.mjs +37 -0
- package/src/server/routes/entries.mjs +170 -0
- package/src/server/routes/ready.mjs +122 -0
- package/src/server/serve.mjs +396 -0
- package/src/server/unpack/capacity.mjs +217 -0
- package/src/server/unpack/mem-probe.mjs +27 -0
- package/src/server/unpack/plugin-runner.mjs +529 -0
- package/src/server/unpack/plugin-store.mjs +736 -0
- package/src/server/unpack/probe-store.mjs +73 -0
- package/src/server/unpack/routes.mjs +359 -0
- package/src/server/unpack/runtime/exec.mjs +558 -0
- package/src/server/unpack/runtime/index.mjs +141 -0
- package/src/server/unpack/runtime/node-vm.mjs +111 -0
- package/src/server/unpack/unpack-worker.mjs +105 -0
- package/src/server/unpack/worker-client.mjs +184 -0
- package/src/server/unpack/write-result.mjs +84 -0
- package/src/shared/bundle-shape.mjs +83 -0
- package/src/shared/entry-reasons.mjs +119 -0
- package/src/shared/names.mjs +45 -0
- package/src/shared/type-labels.mjs +31 -0
- package/src/web/README.md +193 -0
- package/src/web/app/anim-list.js +222 -0
- package/src/web/app/background-solids.js +150 -0
- package/src/web/app/backgrounds.js +152 -0
- package/src/web/app/camera-log.js +28 -0
- package/src/web/app/context.js +72 -0
- package/src/web/app/dir-picker.js +242 -0
- package/src/web/app/entry-report.js +71 -0
- package/src/web/app/memory-bars.js +91 -0
- package/src/web/app/memory-budget.js +228 -0
- package/src/web/app/memory-guard.js +204 -0
- package/src/web/app/model-actions.js +682 -0
- package/src/web/app/renderer-setting.js +70 -0
- package/src/web/app/root-manager.js +254 -0
- package/src/web/app/status.js +138 -0
- package/src/web/app/topbar-actions.js +357 -0
- package/src/web/app/transport-actions.js +297 -0
- package/src/web/app/ui-state.js +559 -0
- package/src/web/app/url-params.js +171 -0
- package/src/web/app/view-settings.js +186 -0
- package/src/web/components.css +524 -0
- package/src/web/debug/diag-hud.js +95 -0
- package/src/web/debug/drag.js +369 -0
- package/src/web/debug/log.js +320 -0
- package/src/web/debug/model-cmp.js +292 -0
- package/src/web/debug/panel.css +474 -0
- package/src/web/debug/panel.js +712 -0
- package/src/web/debug/race-probe.js +82 -0
- package/src/web/debug/upload.js +130 -0
- package/src/web/engine/adapters/atlas.js +47 -0
- package/src/web/engine/adapters/index.js +22 -0
- package/src/web/engine/adapters/renderer.js +223 -0
- package/src/web/engine/adapters/skeleton.js +105 -0
- package/src/web/engine/adapters/texture.js +64 -0
- package/src/web/engine/animation.js +181 -0
- package/src/web/engine/appearance.js +136 -0
- package/src/web/engine/camera-adapter.js +212 -0
- package/src/web/engine/effect-chain.js +138 -0
- package/src/web/engine/engine-state.js +120 -0
- package/src/web/engine/filters.js +224 -0
- package/src/web/engine/fit.js +197 -0
- package/src/web/engine/frame-gate.js +33 -0
- package/src/web/engine/gl-driver.js +56 -0
- package/src/web/engine/gl.js +77 -0
- package/src/web/engine/index.js +20 -0
- package/src/web/engine/loader.js +55 -0
- package/src/web/engine/model-info.js +26 -0
- package/src/web/engine/pipeline.js +200 -0
- package/src/web/engine/player.js +427 -0
- package/src/web/engine/pma/blend.js +76 -0
- package/src/web/engine/pma/detect.js +118 -0
- package/src/web/engine/pma/index.js +8 -0
- package/src/web/engine/pma/resolve.js +112 -0
- package/src/web/engine/pma/upload.js +127 -0
- package/src/web/engine/pma.js +5 -0
- package/src/web/engine/render-frame.js +93 -0
- package/src/web/engine/runtime-capabilities.js +209 -0
- package/src/web/engine/runtime.js +37 -0
- package/src/web/engine/skins.js +165 -0
- package/src/web/engine/textures.js +174 -0
- package/src/web/index.html +117 -0
- package/src/web/infra/api.js +531 -0
- package/src/web/infra/diag.js +509 -0
- package/src/web/infra/document.js +269 -0
- package/src/web/infra/dom.js +31 -0
- package/src/web/infra/events.mjs +27 -0
- package/src/web/infra/log.js +153 -0
- package/src/web/infra/mem-stats.js +112 -0
- package/src/web/infra/plugin-host-registration.mjs +285 -0
- package/src/web/infra/plugin-host-report.mjs +32 -0
- package/src/web/infra/plugin-host-unload.mjs +79 -0
- package/src/web/infra/plugin-host-unpack.mjs +899 -0
- package/src/web/infra/plugin-host-warmup.mjs +180 -0
- package/src/web/infra/plugin-host.js +628 -0
- package/src/web/infra/ready.js +461 -0
- package/src/web/infra/schedule.js +51 -0
- package/src/web/infra/shortcuts.js +129 -0
- package/src/web/infra/slot-registry.js +402 -0
- package/src/web/infra/vfs.js +247 -0
- package/src/web/live2d/bridge.js +400 -0
- package/src/web/live2d/model.js +321 -0
- package/src/web/live2d/overlay.js +142 -0
- package/src/web/live2d/pixi-app.js +111 -0
- package/src/web/live2d/playback-probe.js +92 -0
- package/src/web/live2d/probe-geometry.js +329 -0
- package/src/web/live2d/probe.js +119 -0
- package/src/web/live2d/release.js +66 -0
- package/src/web/live2d/runtime-adapter.js +289 -0
- package/src/web/live2d/stage-ctx.js +152 -0
- package/src/web/live2d/stage.js +204 -0
- package/src/web/live2d/vfs-settings.js +174 -0
- package/src/web/live2d/view.js +89 -0
- package/src/web/main.js +629 -0
- package/src/web/panel/background-list.js +373 -0
- package/src/web/panel/bg-source.js +291 -0
- package/src/web/panel/color-picker.css +199 -0
- package/src/web/panel/color-picker.js +278 -0
- package/src/web/panel/dialog.css +116 -0
- package/src/web/panel/dialog.js +173 -0
- package/src/web/panel/dom.js +35 -0
- package/src/web/panel/icons.js +58 -0
- package/src/web/panel/memory-bar.js +195 -0
- package/src/web/panel/panel-settings.js +1263 -0
- package/src/web/panel/panel.css +1304 -0
- package/src/web/panel/render-choice.js +104 -0
- package/src/web/panel/schema.js +784 -0
- package/src/web/panel/settings-io.js +291 -0
- package/src/web/panel/shell-controls.js +274 -0
- package/src/web/panel/slider.js +210 -0
- package/src/web/panel/toolbar-schema.js +111 -0
- package/src/web/panel/toolbar.js +195 -0
- package/src/web/panel/transport-picker.js +375 -0
- package/src/web/panel/transport.js +321 -0
- package/src/web/panel/view.js +553 -0
- package/src/web/plugin/unpack-worker.js +185 -0
- package/src/web/render/camera/constants.js +60 -0
- package/src/web/render/camera/index.js +123 -0
- package/src/web/render/camera/spine.js +124 -0
- package/src/web/render/capture.js +30 -0
- package/src/web/render/interaction.js +527 -0
- package/src/web/render/live2d.js +627 -0
- package/src/web/render/playback.js +47 -0
- package/src/web/render/reset-view.js +29 -0
- package/src/web/render/spine.js +386 -0
- package/src/web/render/surface-switch.js +176 -0
- package/src/web/render/surface.js +571 -0
- package/src/web/render/surfaces.js +70 -0
- package/src/web/roster/filter.js +31 -0
- package/src/web/roster/list.js +276 -0
- package/src/web/roster/persist.js +92 -0
- package/src/web/roster/reveal.js +77 -0
- package/src/web/roster/rows.js +248 -0
- package/src/web/roster/snapshot.js +39 -0
- package/src/web/roster/type-filter.js +97 -0
- package/src/web/roster/view.js +761 -0
- package/src/web/spine/canvas-api-groups.js +267 -0
- package/src/web/spine/canvas-api.js +97 -0
- package/src/web/spine/canvas-document.js +80 -0
- package/src/web/spine/canvas-engine.js +193 -0
- package/src/web/spine/canvas-entry.js +28 -0
- package/src/web/spine/canvas-pixels.js +36 -0
- package/src/web/spine/canvas-runtime.js +127 -0
- package/src/web/spine/canvas-view.js +108 -0
- package/src/web/spine/canvas.js +7 -0
- package/src/web/spine/runtime-hint.js +73 -0
- package/src/web/spine/runtimes.js +212 -0
- package/src/web/style.css +493 -0
- package/tool/lib/bundle-probe-node.mjs +121 -0
- package/tool/lib/paths.mjs +20 -0
- package/tool/lib/runtime-guides.mjs +65 -0
- package/tool/lib/runtime-package.mjs +403 -0
- package/tool/lib/spine-meta.mjs +227 -0
- package/tool/lib/spine-motions.mjs +104 -0
- package/tool/parse-jsonc.mjs +86 -0
- package/tool/resolve-live2d-runtimes.mjs +276 -0
- package/tool/spine-motion-rules.json +101 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// app/topbar-actions.js —— 顶栏两个"真动作":截图导出、影院模式
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独成模块:main.js 只做装配(≤400 行),动作实现放这里,顶栏与面板无关。
|
|
4
|
+
//
|
|
5
|
+
// 两条实现要点:
|
|
6
|
+
// ① 截图不能直接 canvas.toDataURL():WebGL 默认不保留绘制缓冲,合成后内容可能已经没了。
|
|
7
|
+
// 这里走 gl.readPixels(刚画完的同一任务里取),再翻转到 2D 画布,最后 toBlob 下载。
|
|
8
|
+
// Live2D 覆盖层叠加同一张图上(drawImage),所以两种渲染器都能截到。
|
|
9
|
+
// ② 影院模式只是切 <html data-cinema>,显隐与位移全在 style.css 里;JS 只负责状态切换
|
|
10
|
+
// 与"静止数秒淡出播放条"(data-idle),不碰任何几何。
|
|
11
|
+
|
|
12
|
+
import { stage, state } from './context.js';
|
|
13
|
+
import { stageLog, stageStatus } from './status.js';
|
|
14
|
+
import { repaintActive } from '../render/playback.js';
|
|
15
|
+
import * as uiState from './ui-state.js';
|
|
16
|
+
import { captureActive } from '../render/capture.js';
|
|
17
|
+
import { flushNow, resetStages, trackElementStage } from '../infra/ready.js';
|
|
18
|
+
|
|
19
|
+
/** 影院模式下静止多久把播放条淡出(毫秒);鼠标一动就回来 */
|
|
20
|
+
const CINEMA_IDLE_MS = 3000;
|
|
21
|
+
|
|
22
|
+
let idleTimer = null;
|
|
23
|
+
/** 静止唤醒的监听器引用:单独存,不能挂在定时器句柄上(浏览器里 timeout id 是数字) */
|
|
24
|
+
let idleWake = null;
|
|
25
|
+
|
|
26
|
+
/** 文件名里的模型名:去掉路径分隔与空白,避免下载文件名非法 */
|
|
27
|
+
function safeName(raw) {
|
|
28
|
+
return String(raw || 'model')
|
|
29
|
+
.replace(/[\\/:*?"<>|\s]+/g, '_')
|
|
30
|
+
.slice(0, 48) || 'model';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 时间戳:YYYYMMDD-HHMMSS(本地时间,用户看文件名就能认时间) */
|
|
34
|
+
function stamp(d) {
|
|
35
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
36
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-`
|
|
37
|
+
+ `${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 截图导出:canvas → blob → 触发下载;文件名带模型名与时间戳。
|
|
42
|
+
* 内容同时留在 __WEB__.screenshotExport 供自动化断言(无头环境无法确认真实落盘)。
|
|
43
|
+
*/
|
|
44
|
+
export function exportScreenshot() {
|
|
45
|
+
// 先让**当前渲染面**补一帧:暂停时引擎/渲染循环都停着,不补就会截到旧帧或空帧
|
|
46
|
+
try { repaintActive(state.surface); } catch { /* 画不出新帧就用现成缓冲 */ }
|
|
47
|
+
try {
|
|
48
|
+
const engine = stage && stage.debug ? stage.debug().engine : null;
|
|
49
|
+
if (engine && typeof engine.presentFirstFrame === 'function') engine.presentFirstFrame();
|
|
50
|
+
} catch { /* 引擎不支持就用现成缓冲 */ }
|
|
51
|
+
// 画面来源交给**当前渲染器**(以前这里写死 #stage-canvas,Live2D 面会导到上次 Spine 的残留帧)
|
|
52
|
+
const composed = captureActive(state.surface);
|
|
53
|
+
if (!composed) {
|
|
54
|
+
stageLog('error', '截图失败:当前渲染面尚未就绪', { renderer: String(state.currentRenderer && state.currentRenderer.id || '') });
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
// 模型名优先取名册选中的那个;直连 stage.load(脚本/恢复)时没有 selectedModel,
|
|
58
|
+
// 回落到引擎侧记的模型名,避免导出成 modelstage-model-*.png 这种没信息量的名字
|
|
59
|
+
let modelName = (window.__WEB__ && window.__WEB__.state && window.__WEB__.state.selectedModel)
|
|
60
|
+
? window.__WEB__.state.selectedModel.name : null;
|
|
61
|
+
if (!modelName) {
|
|
62
|
+
try {
|
|
63
|
+
const snap = stage && typeof stage.stats === 'function' ? stage.stats() : null;
|
|
64
|
+
modelName = (snap && snap.model && snap.model.name) || null;
|
|
65
|
+
} catch { modelName = null; }
|
|
66
|
+
}
|
|
67
|
+
const model = modelName || 'model';
|
|
68
|
+
const filename = `modelstage-${safeName(model)}-${stamp(new Date())}.png`;
|
|
69
|
+
const finish = (bytes) => {
|
|
70
|
+
window.__WEB__ && (window.__WEB__.screenshotExport = {
|
|
71
|
+
filename, bytes, width: composed.width, height: composed.height,
|
|
72
|
+
source: composed.source, digest: composed.digest, nonEmpty: composed.nonEmpty, model,
|
|
73
|
+
});
|
|
74
|
+
document.documentElement.dataset.screenshot = `${filename}|${bytes}`;
|
|
75
|
+
stageStatus(`已导出截图:${filename}(${composed.width}×${composed.height},${bytes} 字节)`, 'info', { show: true });
|
|
76
|
+
};
|
|
77
|
+
try {
|
|
78
|
+
if (typeof composed.canvas.toBlob === 'function') {
|
|
79
|
+
composed.canvas.toBlob((blob) => {
|
|
80
|
+
const bytes = blob ? blob.size : 0;
|
|
81
|
+
if (blob) {
|
|
82
|
+
const url = URL.createObjectURL(blob);
|
|
83
|
+
const link = document.createElement('a');
|
|
84
|
+
link.href = url;
|
|
85
|
+
link.download = filename;
|
|
86
|
+
document.body.append(link);
|
|
87
|
+
link.click();
|
|
88
|
+
link.remove();
|
|
89
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
90
|
+
}
|
|
91
|
+
finish(bytes);
|
|
92
|
+
}, 'image/png');
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
// 极老环境兜底:dataURL 直接当下载地址
|
|
96
|
+
const href = composed.canvas.toDataURL('image/png');
|
|
97
|
+
const link = document.createElement('a');
|
|
98
|
+
link.href = href;
|
|
99
|
+
link.download = filename;
|
|
100
|
+
document.body.append(link);
|
|
101
|
+
link.click();
|
|
102
|
+
link.remove();
|
|
103
|
+
finish(href.length);
|
|
104
|
+
return true;
|
|
105
|
+
} catch (err) {
|
|
106
|
+
stageLog('error', `截图失败:${(err && err.message) || err}`, { err: String((err && err.message) || err) });
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 影院模式下的静止淡出:鼠标一动把 data-idle 切回 off,静止 CINEMA_IDLE_MS 后再切 on */
|
|
112
|
+
function startIdleWatch() {
|
|
113
|
+
stopIdleWatch();
|
|
114
|
+
const root = document.documentElement;
|
|
115
|
+
const wake = () => {
|
|
116
|
+
root.dataset.idle = 'off';
|
|
117
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
118
|
+
idleTimer = setTimeout(() => {
|
|
119
|
+
if (root.dataset.cinema === 'on') root.dataset.idle = 'on';
|
|
120
|
+
}, CINEMA_IDLE_MS);
|
|
121
|
+
};
|
|
122
|
+
wake();
|
|
123
|
+
idleWake = wake;
|
|
124
|
+
window.addEventListener('pointermove', wake, { passive: true });
|
|
125
|
+
window.addEventListener('pointerdown', wake, { passive: true });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function stopIdleWatch() {
|
|
129
|
+
if (idleTimer) {
|
|
130
|
+
clearTimeout(idleTimer);
|
|
131
|
+
idleTimer = null;
|
|
132
|
+
}
|
|
133
|
+
if (idleWake) {
|
|
134
|
+
window.removeEventListener('pointermove', idleWake);
|
|
135
|
+
window.removeEventListener('pointerdown', idleWake);
|
|
136
|
+
idleWake = null;
|
|
137
|
+
}
|
|
138
|
+
document.documentElement.dataset.idle = 'off';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 过渡相关的阶段 → 元素选择器(只这三处会"事后过渡") */
|
|
142
|
+
const STAGE_SELECTORS = {
|
|
143
|
+
topbar: '#app-header',
|
|
144
|
+
sidebarLeft: '#left-panel',
|
|
145
|
+
sidebarRight: '#right-panel',
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** 各阶段当前的跟踪句柄(stop()):重置前必须先停掉,否则两个跟踪器叠着跑 */
|
|
149
|
+
const stageHandles = new Map();
|
|
150
|
+
/** 待重新起算的阶段(同帧合并用)与其 rAF 句柄 */
|
|
151
|
+
const pendingRetrack = new Set();
|
|
152
|
+
let rafId = 0;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 过渡开始时重新起算某阶段的就绪状态。
|
|
156
|
+
*
|
|
157
|
+
* 为什么需要:topbar / sidebarLeft / sidebarRight 在启动时走到 interactive 就 stop() 了,
|
|
158
|
+
* 之后影院模式、侧栏收展**不再上报任何状态**;于是 `waitStage(name,'settled')` 会拿启动时
|
|
159
|
+
* 的旧 interactive 立刻返回(rank 已够),等于没等——这是测试里那些"事后过渡等待"换不掉 sleep 的根因。
|
|
160
|
+
* 做法:先停旧句柄(连续快点以最后一次为准,不叠跟踪器)→ resetStage 回到初始态 →
|
|
161
|
+
* 用与启动时同一套判据重新跟踪(无动画 + 两次包围盒一致 ⇒ settled),不另写一套。
|
|
162
|
+
* @param {string} name 阶段名
|
|
163
|
+
* @returns {boolean} 是否重新起了跟踪
|
|
164
|
+
*/
|
|
165
|
+
export function retrackStage(name) {
|
|
166
|
+
return retrackStages([name]) > 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 批量版:一次交互牵动多个阶段时用它——**整批只上报一次**就绪快照。
|
|
171
|
+
*
|
|
172
|
+
* **延到下一帧执行**(不是立即):调用点都是"改完 class/dataset 就调",
|
|
173
|
+
* 若同一帧里既做样式重算又做就绪上报(resetStage → flushNow → fetch 的序列化+发起),
|
|
174
|
+
* 两者会抢同一帧;而且就绪信号的语义本来就是"等布局稳定",早一帧上报反而更容易抓到中间态。
|
|
175
|
+
* 先把 class 变化交还浏览器去重算,下一帧再起算就绪状态。
|
|
176
|
+
* 同帧内多次调用会合并成一次(连点仍以最后一次为准,不叠加跟踪器)。
|
|
177
|
+
* @param {string[]} names 阶段名数组
|
|
178
|
+
* @returns {number} 已受理的阶段数(实际执行在下一帧)
|
|
179
|
+
*/
|
|
180
|
+
export function retrackStages(names) {
|
|
181
|
+
const list = (Array.isArray(names) ? names : []).filter((n) => STAGE_SELECTORS[n]);
|
|
182
|
+
if (!list.length) return 0;
|
|
183
|
+
for (const n of list) pendingRetrack.add(n);
|
|
184
|
+
if (!rafId) {
|
|
185
|
+
// 无 rAF 的环境(测试垫片/极老浏览器)就同步执行,保证功能不丢
|
|
186
|
+
rafId = typeof requestAnimationFrame === 'function'
|
|
187
|
+
? requestAnimationFrame(runPendingRetrack)
|
|
188
|
+
: (runPendingRetrack(), 0);
|
|
189
|
+
}
|
|
190
|
+
return list.length;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 同帧合并后的真正执行体(原先 retrackStages 的实现) */
|
|
194
|
+
function runPendingRetrack() {
|
|
195
|
+
rafId = 0;
|
|
196
|
+
const list = [...pendingRetrack];
|
|
197
|
+
pendingRetrack.clear();
|
|
198
|
+
if (!list.length) return;
|
|
199
|
+
// ① 先停旧句柄(连点以最后一次为准,不叠跟踪器)
|
|
200
|
+
for (const n of list) {
|
|
201
|
+
const prev = stageHandles.get(n);
|
|
202
|
+
if (prev) { try { prev(); } catch { /* 已停 */ } stageHandles.delete(n); }
|
|
203
|
+
}
|
|
204
|
+
// ② 批量重置:这里**不单独上报**——紧接着的重新跟踪会在第一拍 markStage('mounted'),
|
|
205
|
+
// 两者落在同一个去抖窗口里 ⇒ 整次交互只发 1 个 POST(实测:影院切换 1 次 = 1 次上报)
|
|
206
|
+
resetStages(list, { flush: false });
|
|
207
|
+
// ③ 用启动时同一套判据重新跟踪
|
|
208
|
+
let started = 0;
|
|
209
|
+
for (const n of list) {
|
|
210
|
+
const el = document.querySelector(STAGE_SELECTORS[n]);
|
|
211
|
+
if (!el) continue;
|
|
212
|
+
const stop = trackElementStage({ name: n, el });
|
|
213
|
+
if (typeof stop === 'function') stageHandles.set(n, stop);
|
|
214
|
+
started += 1;
|
|
215
|
+
}
|
|
216
|
+
// 兜底:元素不在(或跟踪没起来)就没人触发上报,此时必须自己把 reset 发出去
|
|
217
|
+
if (!started) flushNow();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 影院模式开/关:只切 <html data-cinema>,显隐与位移全交给 style.css。
|
|
222
|
+
* @param {boolean} [force] 传 true/false 强制设值;不传则翻转
|
|
223
|
+
* @returns {boolean} 切换后的状态
|
|
224
|
+
*/
|
|
225
|
+
export function toggleCinema(force) {
|
|
226
|
+
const root = document.documentElement;
|
|
227
|
+
const next = typeof force === 'boolean' ? force : root.dataset.cinema !== 'on';
|
|
228
|
+
root.dataset.cinema = next ? 'on' : 'off';
|
|
229
|
+
// 顶栏与两侧栏一起做位移过渡 ⇒ 三个阶段的就绪状态重新起算(进入/退出都算)。
|
|
230
|
+
// 用批量接口:整批只上报一次(逐个重置会各发一次 POST,慢机器上会变成一串 ERR_ABORTED)。
|
|
231
|
+
retrackStages(['topbar', 'sidebarLeft', 'sidebarRight']);
|
|
232
|
+
const btn = document.getElementById('btn-cinema');
|
|
233
|
+
if (btn) btn.setAttribute('aria-pressed', String(next));
|
|
234
|
+
syncSidebarButtons(); // 影院模式隐藏了两侧栏,两个开关的高亮也要跟着灭/亮
|
|
235
|
+
if (next) startIdleWatch(); else stopIdleWatch();
|
|
236
|
+
stageStatus(`影院模式:${next ? '开(隐藏顶栏与左右栏,Esc 退出)' : '关'}`, 'info', { show: true });
|
|
237
|
+
return next;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 边栏"实际可见"判定:收起是靠 translateX 移出视口,所以看几何而不是看状态变量。
|
|
242
|
+
* 这样影院模式、面板头「收起菜单」、按钮切换、CSS 过渡中都能得到同一口径的答案。
|
|
243
|
+
* @param {HTMLElement|null} el
|
|
244
|
+
* @param {'left'|'right'} side
|
|
245
|
+
* @returns {boolean}
|
|
246
|
+
*/
|
|
247
|
+
function isPanelOnScreen(el, side) {
|
|
248
|
+
if (!el) return false;
|
|
249
|
+
const root = document.documentElement;
|
|
250
|
+
// ① 状态量优先:影院模式 / 收起状态 / is-hidden 都是"确定要隐藏",过渡动画没走完也该立刻判定为隐藏,
|
|
251
|
+
// 否则点击后要等 transitionend 才更新高亮,读起来像"慢了半拍"。
|
|
252
|
+
const declaredHidden = side === 'left'
|
|
253
|
+
? (root.dataset.cinema === 'on' || root.dataset.left === 'off' || el.classList.contains('is-hidden'))
|
|
254
|
+
: (root.dataset.cinema === 'on' || root.dataset.right === 'off');
|
|
255
|
+
if (declaredHidden) return false;
|
|
256
|
+
// ② 几何兜底:没有被状态量宣布隐藏时,以真实位置为准(窗口过窄/面板被外部样式挤掉等情况)
|
|
257
|
+
const r = el.getBoundingClientRect();
|
|
258
|
+
if (!r.width || !r.height) return false;
|
|
259
|
+
return side === 'left' ? r.right > 8 : r.left < window.innerWidth - 8;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 把顶栏两个边栏开关的高亮同步到边栏的真实显隐状态。
|
|
264
|
+
* 高亮只认这一处结论:按钮自己 toggle class 不算,影院模式关掉边栏时也必须跟着灭。
|
|
265
|
+
* @returns {{leftOn: boolean, rightOn: boolean}}
|
|
266
|
+
*/
|
|
267
|
+
export function syncSidebarButtons() {
|
|
268
|
+
const leftOn = isPanelOnScreen(document.getElementById('left-panel'), 'left');
|
|
269
|
+
const rightOn = isPanelOnScreen(document.getElementById('right-panel'), 'right');
|
|
270
|
+
const btnLeft = document.getElementById('btn-library');
|
|
271
|
+
const btnRight = document.getElementById('btn-menu');
|
|
272
|
+
if (btnLeft) {
|
|
273
|
+
btnLeft.setAttribute('aria-pressed', String(leftOn)); // 高亮态由 aria-pressed 驱动(样式在 components.css)
|
|
274
|
+
btnLeft.setAttribute('aria-expanded', String(leftOn));
|
|
275
|
+
btnLeft.dataset.sidebarState = leftOn ? 'shown' : 'hidden';
|
|
276
|
+
}
|
|
277
|
+
if (btnRight) {
|
|
278
|
+
btnRight.setAttribute('aria-pressed', String(rightOn));
|
|
279
|
+
btnRight.dataset.sidebarState = rightOn ? 'shown' : 'hidden';
|
|
280
|
+
}
|
|
281
|
+
document.documentElement.dataset.leftState = leftOn ? 'on' : 'off';
|
|
282
|
+
document.documentElement.dataset.rightState = rightOn ? 'on' : 'off';
|
|
283
|
+
return { leftOn, rightOn };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 订阅所有会改变边栏显隐的动作(按钮/影院/收起/过渡/缩放),统一回调同步高亮。
|
|
288
|
+
* 只绑一次;CSS 过渡结束再同步一次,避免"点完立刻读位置"读到动画中间值。
|
|
289
|
+
*/
|
|
290
|
+
export function bindSidebarStateSync() {
|
|
291
|
+
if (bindSidebarStateSync.done) return false;
|
|
292
|
+
bindSidebarStateSync.done = true;
|
|
293
|
+
for (const id of ['left-panel', 'right-panel']) {
|
|
294
|
+
const el = document.getElementById(id);
|
|
295
|
+
if (el) el.addEventListener('transitionend', () => syncSidebarButtons());
|
|
296
|
+
}
|
|
297
|
+
window.addEventListener('resize', () => syncSidebarButtons());
|
|
298
|
+
syncSidebarButtons();
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 右侧控制面板显隐(顶栏「菜单」与面板头「收起菜单」共用一个开关)。
|
|
304
|
+
* 状态写在 <html data-right>,位移与淡出全在 style.css;顶栏按钮 aria-pressed 同步,
|
|
305
|
+
* 这样"面板被收起后怎么找回来"有明确答案:顶栏「菜单」。
|
|
306
|
+
* @param {boolean} [force] 传 true/false 强制设值;不传则翻转
|
|
307
|
+
* @returns {boolean} 切换后的状态(true=显示)
|
|
308
|
+
*/
|
|
309
|
+
export function toggleRightPanel(force) {
|
|
310
|
+
const root = document.documentElement;
|
|
311
|
+
const open = typeof force === 'boolean' ? force : root.dataset.right === 'off';
|
|
312
|
+
root.dataset.right = open ? 'on' : 'off';
|
|
313
|
+
// S4:记下这次手动选择(左栏沿用当前值,互不覆盖)
|
|
314
|
+
uiState.setLayout({ rightVisible: open });
|
|
315
|
+
// 右栏位移是一次过渡 ⇒ 它的就绪状态重新起算(只重置右栏,别的阶段不受影响)
|
|
316
|
+
retrackStage('sidebarRight');
|
|
317
|
+
syncSidebarButtons(); // 高亮跟随真实显隐(不在这里自己写 class)
|
|
318
|
+
stageStatus(`右侧控制面板:${open ? '显示' : '收起(顶栏「菜单」可再打开)'}`, 'info', { show: true });
|
|
319
|
+
return open;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 退出影院模式(**仅在影院态下才有动作**)。
|
|
324
|
+
*
|
|
325
|
+
* 为什么要有这个守卫:toggleCinema(false) 在"本来就没进影院"时也会写状态、重排阶段、记一条日志,
|
|
326
|
+
* 按 Esc 反复触发会刷出一串无意义的「影院模式:关」。Esc 的语义是"只退不进"。
|
|
327
|
+
* @returns {boolean} 是否真的退出了
|
|
328
|
+
*/
|
|
329
|
+
export function exitCinemaIfActive() {
|
|
330
|
+
if (document.documentElement.dataset.cinema !== 'on') return false;
|
|
331
|
+
toggleCinema(false);
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 影院模式的**鼠标**入口:舞台中心区域双击进出。
|
|
337
|
+
* 为什么限定中心区域:四角/边缘留给拖拽与面板操作,避免"想微调视角却把界面收掉"。
|
|
338
|
+
* 双击判定用原生 dblclick(两次 click 间隔由浏览器判定),无需自己计时。
|
|
339
|
+
*
|
|
340
|
+
* 键盘入口不在这里:Esc 退出 / H 切换统一登记在 infra/shortcuts.js(唯一键盘真相源)。
|
|
341
|
+
*/
|
|
342
|
+
export function bindCinemaKeys() {
|
|
343
|
+
const stageEl = document.getElementById('stage');
|
|
344
|
+
if (stageEl) {
|
|
345
|
+
stageEl.addEventListener('dblclick', (e) => {
|
|
346
|
+
const box = stageEl.getBoundingClientRect();
|
|
347
|
+
if (!box.width || !box.height) return;
|
|
348
|
+
// 中心 60% 区域内才算"中心双击"
|
|
349
|
+
const nx = (e.clientX - box.left) / box.width;
|
|
350
|
+
const ny = (e.clientY - box.top) / box.height;
|
|
351
|
+
const inCenter = nx > 0.2 && nx < 0.8 && ny > 0.2 && ny < 0.8;
|
|
352
|
+
if (!inCenter) return;
|
|
353
|
+
e.preventDefault();
|
|
354
|
+
toggleCinema();
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// app/transport-actions.js —— 底栏播放条上的动作实现
|
|
2
|
+
//
|
|
3
|
+
// 播放条控件(播放/暂停、上/下一条变体、背景切换、动作下拉、皮肤下拉)都只操作"当前内容",
|
|
4
|
+
// 与渲染器实现无关,所以统一放这里。状态一律经 state.transport 的句柄落到 DOM,
|
|
5
|
+
// 同时打 dataset 供自动化断言读。
|
|
6
|
+
|
|
7
|
+
import { dom, stage, state } from './context.js';
|
|
8
|
+
import { setPlaybackPaused } from '../render/playback.js';
|
|
9
|
+
import { getRendererSetting, setRendererSetting } from './renderer-setting.js';
|
|
10
|
+
import { stageLog, stageStatus } from './status.js';
|
|
11
|
+
import { buildChars, createNameIndex, mergeNameTable, loadRootNames } from '../roster/list.js';
|
|
12
|
+
import { formatError } from '../infra/api.js';
|
|
13
|
+
import {
|
|
14
|
+
buildAnimationList, isCycleLoop, labelWithDuration, notePlaying, playAnimation, setCycleLoop,
|
|
15
|
+
} from './anim-list.js';
|
|
16
|
+
|
|
17
|
+
/** 播放/暂停:切换引擎播放并同步按钮按下态与 dataset(进 CAMERA 之外的状态日志) */
|
|
18
|
+
/**
|
|
19
|
+
* 播放/暂停统一入口(render/playback.js):按当前渲染面派发,
|
|
20
|
+
* 调用方不判断"现在是哪一面",也不直连 stage(stage 恒为 Spine 舞台)。
|
|
21
|
+
* @param {boolean} paused
|
|
22
|
+
*/
|
|
23
|
+
function applyPlaybackPaused(paused) {
|
|
24
|
+
try { return setPlaybackPaused(state.surface, paused); } catch { return 'none'; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 播放状态的唯一真相 = state.playing。
|
|
29
|
+
* UI(图标 / title / aria-pressed / dataset)与引擎状态都由它派生,不各存一份。
|
|
30
|
+
* @param {boolean} on true=播放中,false=已暂停
|
|
31
|
+
* @param {{silent?: boolean}} [opts]
|
|
32
|
+
*/
|
|
33
|
+
function setPlaying(on, opts = {}) {
|
|
34
|
+
const next = !!on;
|
|
35
|
+
const prev = state.playing === true;
|
|
36
|
+
state.playing = next;
|
|
37
|
+
state.playbackPath = applyPlaybackPaused(!next); // 记账:便于验收贴"走了哪条通路"
|
|
38
|
+
syncPlayUi();
|
|
39
|
+
if (!opts.silent && prev !== next) stageStatus(next ? '继续播放' : '已暂停(画面与动作都停住)', 'info');
|
|
40
|
+
return next;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** UI 派生:图标(播放中显示"暂停"语义)、title、aria-pressed、dataset 全部跟随 state.playing */
|
|
44
|
+
function syncPlayUi() {
|
|
45
|
+
const playing = state.playing !== false;
|
|
46
|
+
if (state.transport) state.transport.setPressed('transport-play', playing);
|
|
47
|
+
if (state.transport && state.transport.setIcon) state.transport.setIcon('transport-play', playing ? 'pause' : 'play');
|
|
48
|
+
document.documentElement.dataset.transportPlaying = playing ? 'on' : 'off';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 动作混合(过渡渐变)默认开启,且**面板里没有这个开关**(schema 标了 hidden)。
|
|
53
|
+
* 没有开关就没人会去打开它,所以由应用层在首次装载时显式落地一次;
|
|
54
|
+
* 已生效则不重复下发(幂等,避免每次换模型都改一次引擎设置)。
|
|
55
|
+
*/
|
|
56
|
+
function ensureMixModeOn() {
|
|
57
|
+
if (state.mixModeEnsured === true) return;
|
|
58
|
+
state.mixModeEnsured = true;
|
|
59
|
+
try { setRendererSetting('mixMode', true); } catch { /* 该面不支持就跳过 */ }
|
|
60
|
+
state.mixMode = true;
|
|
61
|
+
document.documentElement.dataset.mixMode = 'on';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 底栏「播放/暂停」:切换 */
|
|
65
|
+
function toggleTransportPlay() {
|
|
66
|
+
setPlaying(state.playing === false);
|
|
67
|
+
}
|
|
68
|
+
/** 单动循环:切换 settings.loop(引擎 setLoop 走通用 setter) */
|
|
69
|
+
/**
|
|
70
|
+
* 判断两个条目是否属于「同一个角色(同一组变体)」。
|
|
71
|
+
*
|
|
72
|
+
* 字段来源不统一:catalog 条目用 `srcDir`(根内相对目录),旧 models 条目用 `directory`(带根名前缀)。
|
|
73
|
+
* 优先比 srcDir,缺失时回落到 directory —— 两边都取不到就判为不同组,宁可不合并也不乱合并。
|
|
74
|
+
* @param {object} m 候选条目
|
|
75
|
+
* @param {object} cur 当前模型
|
|
76
|
+
* @param {string} dir 当前模型的目录(兜底)
|
|
77
|
+
* @returns {boolean}
|
|
78
|
+
*/
|
|
79
|
+
/**
|
|
80
|
+
* 把条目补成"可装载形态"(**兜底**:树里的条已自带 entry.url)。
|
|
81
|
+
*
|
|
82
|
+
* 为什么需要:/api/catalog 的条目自带 `entry.url`,但历史路径(如从别处传入的裸模型对象)
|
|
83
|
+
* 可能只有 `skeletonFile`;这里统一补齐,避免装载入口报"条目缺少资产地址"。
|
|
84
|
+
* @param {object} m 模型条目
|
|
85
|
+
* @returns {object} 补好 entry 的条目(已有 entry 时原样返回)
|
|
86
|
+
*/
|
|
87
|
+
function withEntry(m) {
|
|
88
|
+
if (!m || typeof m !== 'object') return m;
|
|
89
|
+
if (m.entry && m.entry.url) return m;
|
|
90
|
+
const url = (m.skeletonFile && m.skeletonFile.url) || m.modelUrl || null;
|
|
91
|
+
return url ? { ...m, entry: { url } } : m;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 取名册树实例(翻页的池子与"同角色变体"都从它取,见下方说明) */
|
|
95
|
+
function rosterApi() {
|
|
96
|
+
const r = state.roster;
|
|
97
|
+
return r && typeof r.listVariants === 'function' && typeof r.variantsOf === 'function' ? r : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 装载并同步左栏(统一入口;装载实现仍然是 model-actions 的 onSelectModel)。
|
|
102
|
+
* @param {object} model
|
|
103
|
+
*/
|
|
104
|
+
async function applyPicked(model) {
|
|
105
|
+
const { selectModel } = await import('./model-actions.js');
|
|
106
|
+
selectModel(withEntry(model));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 翻变体(同一角色的不同皮肤/服装)。
|
|
111
|
+
* @param {number} dir -1 上一个 / +1 下一个
|
|
112
|
+
* @param {object[]} [peersArg] 已取到的兄弟列表(调用方复用查询结果时传)
|
|
113
|
+
*/
|
|
114
|
+
async function stepVariant(dir, peersArg) {
|
|
115
|
+
const cur = state.selectedModel;
|
|
116
|
+
if (!cur) { stageLog('warn', '切换变体失败:当前没有选中的模型', { reason: 'no-current-model' }); return; }
|
|
117
|
+
const api = rosterApi();
|
|
118
|
+
if (!api) { stageLog('warn', '切换变体失败:名册树尚未就绪', { reason: 'no-roster' }); return; }
|
|
119
|
+
const peers = peersArg || (await api.variantsOf(cur.id));
|
|
120
|
+
if (peers.length < 2) { stageLog('warn', '切换变体失败:当前角色只有 1 个变体', { peers: peers.length }); return; }
|
|
121
|
+
const at = peers.findIndex((p) => p.model && p.model.id === cur.id);
|
|
122
|
+
const next = peers[(Math.max(at, 0) + dir + peers.length) % peers.length];
|
|
123
|
+
if (!next || !next.model) return;
|
|
124
|
+
stageStatus(`切换变体 → ${next.model.name}`, 'info');
|
|
125
|
+
await applyPicked(next.model);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 翻模型(**按左栏树从上到下的顺序**,末尾回绕)。
|
|
130
|
+
*
|
|
131
|
+
* 池子必须是树,不能是某个接口的清单:
|
|
132
|
+
* 树的「游戏」节点是**同名根合并**出来的(例如「碧蓝航线」= azurlane + azurlane_spine + azurlane_bundle),
|
|
133
|
+
* 按单个 rootId 取清单会漏掉同名的其它根 ⇒ 表现为"只能在 live2d 之间翻、翻不到 spine"(实测过的现场)。
|
|
134
|
+
* @param {number} dir -1 上一个 / +1 下一个
|
|
135
|
+
* @param {object[]} [flatArg] 已取到的树序序列
|
|
136
|
+
*/
|
|
137
|
+
async function stepModel(dir, flatArg) {
|
|
138
|
+
const cur = state.selectedModel;
|
|
139
|
+
if (!cur) { stageLog('warn', '切换模型失败:当前没有选中的模型', { reason: 'no-current-model' }); return; }
|
|
140
|
+
const api = rosterApi();
|
|
141
|
+
if (!api) {
|
|
142
|
+
stageLog('warn', '切换模型失败:名册树尚未就绪', { reason: 'no-roster' });
|
|
143
|
+
stageStatus('切换失败:模型列表尚未就绪', 'warn');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
let flat = flatArg;
|
|
147
|
+
if (!flat) {
|
|
148
|
+
try { flat = await api.listVariants(); } catch (err) {
|
|
149
|
+
stageLog('error', `切换模型失败:读取模型列表出错(${formatError(err)})`, { err: formatError(err) });
|
|
150
|
+
stageStatus('切换失败:读取模型列表出错', 'warn');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (!flat || flat.length === 0) {
|
|
155
|
+
stageLog('warn', '切换模型失败:当前没有可切换的模型', { total: 0 });
|
|
156
|
+
stageStatus('当前没有可切换的模型', 'warn');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (flat.length === 1) {
|
|
160
|
+
stageLog('warn', '切换模型失败:库中只有 1 个模型', { total: 1 });
|
|
161
|
+
stageStatus('库中只有 1 个模型,没有可切换的下一个', 'warn');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const at = flat.findIndex((p) => p.model && p.model.id === cur.id);
|
|
165
|
+
const from = at < 0 ? (dir > 0 ? -1 : 0) : at;
|
|
166
|
+
const nextAt = (from + dir + flat.length) % flat.length;
|
|
167
|
+
const next = flat[nextAt];
|
|
168
|
+
if (!next || !next.model) return;
|
|
169
|
+
// 回绕提示只在真的"跳过头"时出现:往后翻到第 0 个、往前翻到最后一个
|
|
170
|
+
if (dir > 0 && nextAt === 0 && at !== 0) stageStatus('已返回第一个模型', 'info');
|
|
171
|
+
else if (dir < 0 && nextAt === flat.length - 1 && at !== flat.length - 1) stageStatus('已到最后一个模型(回绕)', 'info');
|
|
172
|
+
else stageStatus(`切换模型 → ${next.model.name}`, 'info');
|
|
173
|
+
await applyPicked(next.model);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 「上一个 / 下一个」的唯一入口(快捷键 A/S 与底栏两个按钮共用)。
|
|
178
|
+
*
|
|
179
|
+
* 粒度:当前角色有多个变体 ⇒ 翻变体(想看的是同角色另一套衣服);否则 ⇒ 按树序翻模型。
|
|
180
|
+
* @param {number} dir -1 上一个 / +1 下一个
|
|
181
|
+
*/
|
|
182
|
+
async function stepUnit(dir) {
|
|
183
|
+
const cur = state.selectedModel;
|
|
184
|
+
if (!cur) { stageLog('warn', '上一个/下一个失败:当前没有选中的模型', { reason: 'no-current-model' }); return; }
|
|
185
|
+
const api = rosterApi();
|
|
186
|
+
if (!api) {
|
|
187
|
+
stageLog('warn', '上一个/下一个失败:名册树尚未就绪', { reason: 'no-roster' });
|
|
188
|
+
stageStatus('切换失败:模型列表尚未就绪', 'warn');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const peers = await api.variantsOf(cur.id);
|
|
192
|
+
if (peers.length > 1) { await stepVariant(dir, peers); return; }
|
|
193
|
+
await stepModel(dir);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
/** 动作下拉选择:切动画并回填 dataset;右栏高亮与下拉互相跟随(同一份清单) */
|
|
198
|
+
function applyTransportMotion(id) {
|
|
199
|
+
if (!id) return;
|
|
200
|
+
// ① 先立真相:任何"读当前动作"的地方(面板重绘、底栏回显)都必须先看到新值,
|
|
201
|
+
// 否则播放过程中触发的重绘会用旧值把高亮刷回去(实测过的坑)。
|
|
202
|
+
state.transportMotion = String(id);
|
|
203
|
+
document.documentElement.dataset.transportMotion = String(id);
|
|
204
|
+
// ② 再播放:playAnimation 只负责让引擎播,不碰面板(职责分离)
|
|
205
|
+
const ok = playAnimation(id);
|
|
206
|
+
// 当前动作的唯一真相是底栏下拉(契约 §七):不管从哪一处触发,都把下拉同步到这条
|
|
207
|
+
if (state.transport) state.transport.setSelectValue('transport-motion', id);
|
|
208
|
+
// 用自定义事件请求重绘,而不是 import 面板模块:
|
|
209
|
+
// ① 面板句柄在重建瞬间可能是"半成品"(state.panel 尚未挂 refreshAnimations);
|
|
210
|
+
// ② app 与 panel 互相 import 会形成**循环依赖**,实测会让页面脚本异常。
|
|
211
|
+
// 事件解耦:谁关心谁监听,app 层不需要知道面板内部结构。
|
|
212
|
+
if (typeof document !== 'undefined') {
|
|
213
|
+
document.dispatchEvent(new CustomEvent('panel:refresh-animations'));
|
|
214
|
+
}
|
|
215
|
+
if (ok) stageStatus(`动作 → ${id}`, 'info');
|
|
216
|
+
else stageLog('warn', `动作 → ${id}(引擎拒绝,保持原动作)`, { id, reason: 'renderer-rejected' });
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* 循环播放(右栏「动作」组开关,契约 §四):
|
|
220
|
+
* 开 = 按列表顺序逐条播完剩余动作、末尾回到第一条并持续;关 = 停留当前动作重复播。
|
|
221
|
+
* 与「混合模式」正交:混合模式只管切换瞬间的过渡(引擎 mixTime),这里只管"播完接谁",
|
|
222
|
+
* 两者互不调用、互不改写对方状态。
|
|
223
|
+
* @param {boolean} on
|
|
224
|
+
* @returns {boolean} 落地后的状态
|
|
225
|
+
*/
|
|
226
|
+
function applyAnimLoop(on) {
|
|
227
|
+
setCycleLoop(!!on);
|
|
228
|
+
state.animLoop = !!on;
|
|
229
|
+
document.documentElement.dataset.animLoop = on ? 'on' : 'off';
|
|
230
|
+
if (state.panel && typeof state.panel.setChecked === 'function') state.panel.setChecked('ctl-anim-loop', !!on);
|
|
231
|
+
return !!on;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 模型加载完成后填充播放条:动作下拉 + 播放状态 */
|
|
235
|
+
function fillTransportOptions(info) {
|
|
236
|
+
ensureMixModeOn(); // 混合模式默认开启(面板已隐藏该开关,必须由应用层落地)
|
|
237
|
+
const transport = state.transport;
|
|
238
|
+
if (!transport) return;
|
|
239
|
+
// 首次填充顺带刷一次播放 UI:dataset / 图标 / 文案都由 state.playing 派生(幂等)
|
|
240
|
+
syncPlayUi();
|
|
241
|
+
// 与右栏同一份清单(同顺序、同文案、同时长):契约 §一/§三
|
|
242
|
+
const list = buildAnimationList();
|
|
243
|
+
if (list.length) {
|
|
244
|
+
const preferred = (info && info.motion) || '';
|
|
245
|
+
const startId = list.some((x) => x.id === preferred) ? preferred : list[0].id;
|
|
246
|
+
transport.setOptions('transport-motion', list.map((x) => ({ text: labelWithDuration(x), value: x.id })), startId);
|
|
247
|
+
notePlaying(startId);
|
|
248
|
+
}
|
|
249
|
+
// 不在这里直读 stage.stats().playing:stage 恒为 Spine 舞台,Live2D 面会被它带偏,
|
|
250
|
+
// 而且暂停状态只能有一个来源(state.playing)→ 统一走 syncPlayUi 派生。
|
|
251
|
+
syncPlayUi();
|
|
252
|
+
}
|
|
253
|
+
/** 混合模式:两处开关指向同一状态,点任一处都要同步另一处的显示 */
|
|
254
|
+
/**
|
|
255
|
+
* 混合模式(动作组):只控制**动画切换**是否交叉淡化(引擎 mixTime 0.2s / 0)。
|
|
256
|
+
* 与「皮肤」组那个开关是两件独立的事:一个管动作过渡、一个管换肤过渡,
|
|
257
|
+
* 两个开关分开、互不同步。
|
|
258
|
+
*/
|
|
259
|
+
function applyMixMode(on) {
|
|
260
|
+
// 经渲染器下发(INV-01):混合模式 = 动作切换是否交叉淡化,Spine 面有实现、Live2D 面没有
|
|
261
|
+
const { ok } = setRendererSetting('mixMode', !!on);
|
|
262
|
+
const applied = getRendererSetting('mixMode');
|
|
263
|
+
const value = ok && applied === true ? 0.2 : 0;
|
|
264
|
+
state.mixMode = !!on;
|
|
265
|
+
document.documentElement.dataset.mixMode = on ? 'on' : 'off';
|
|
266
|
+
return value;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 混合模式(皮肤组):只控制**换肤**是否交叉淡化。
|
|
271
|
+
* 引擎没有独立的"皮肤混合"参数,所以实现方式是在换肤的那一刻临时套用 0.2s 混合,
|
|
272
|
+
* 换完立刻还原——这样动作与皮肤两个开关互不影响。
|
|
273
|
+
*/
|
|
274
|
+
function applySkinMix(on) {
|
|
275
|
+
state.skinMix = !!on;
|
|
276
|
+
document.documentElement.dataset.skinMix = on ? 'on' : 'off';
|
|
277
|
+
return state.skinMix;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 换肤时按「皮肤混合模式」决定是否临时淡入 */
|
|
281
|
+
function skinSwitchMixTime(fn) {
|
|
282
|
+
// 换肤那一刻临时套用交叉淡化,换完立刻还原——动作/皮肤两个开关互不影响。
|
|
283
|
+
// 走渲染器(INV-01);还原放在 finally 里,异常路径也不让皮肤混合状态漏在临时值上。
|
|
284
|
+
const want = state.skinMix === true;
|
|
285
|
+
setRendererSetting('mixMode', want);
|
|
286
|
+
try {
|
|
287
|
+
return fn();
|
|
288
|
+
} finally {
|
|
289
|
+
// 还原到"用户当前选择"(state.mixMode 才是权威),不是还原到临时值
|
|
290
|
+
setRendererSetting('mixMode', state.mixMode === true);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export {
|
|
295
|
+
toggleTransportPlay, setPlaying, syncPlayUi, stepVariant, stepModel, stepUnit, applyAnimLoop,
|
|
296
|
+
applyTransportMotion, fillTransportOptions, applyMixMode, applySkinMix, skinSwitchMixTime,
|
|
297
|
+
};
|