modelstage 0.0.0-stage → 0.5.24
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 +267 -2
- package/THIRD_PARTY_NOTICES.md +90 -0
- package/bin/modelstage.mjs +101 -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 +58 -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 +647 -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 +334 -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/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 +465 -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 +115 -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 +52 -0
- package/src/web/roster/reveal.js +77 -0
- package/src/web/roster/rows.js +248 -0
- package/src/web/roster/snapshot.js +37 -0
- package/src/web/roster/view.js +670 -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/check-perms.mjs +114 -0
- package/tool/lib/contract-checks.mjs +582 -0
- package/tool/lib/cubism-core.mjs +122 -0
- package/tool/lib/export-bundle.mjs +328 -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/lib/spine-runtime.mjs +131 -0
- package/tool/lib/test-lock.mjs +178 -0
- package/tool/lib/test-report.mjs +133 -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,670 @@
|
|
|
1
|
+
// roster-view.js —— 名册视图层:三级树(游戏 → 角色 → 变体)+ 搜索 + 选中态(M2)
|
|
2
|
+
//
|
|
3
|
+
// 做什么:
|
|
4
|
+
// 1. 一级:按资源根配置的 name 合并成游戏节点(同名根合并);展开该游戏时才请求其各根模型;
|
|
5
|
+
// 2. 二级:角色跨根归并——同一角色在多个根下的模型归到一个角色节点(不是把各根的行拼起来);
|
|
6
|
+
// 3. 三级:变体(模型)。行上带类型标签(Spine / Live2D / 资源包)与来源根 id;
|
|
7
|
+
// 4. 搜索:前端过滤(角色名 / 服装名 / 资源名 / 目录名),命中即自动展开到变体层;
|
|
8
|
+
// 5. 选中态:点变体行高亮并记录(M4 渲染用),状态写 localStorage(键名/结构沿用既有);
|
|
9
|
+
// 6. 顶部「收起」只清空展开状态,面板本身保留(面板显隐由顶栏「模型库」按钮控制)。
|
|
10
|
+
//
|
|
11
|
+
// 状态全部收在 state 里,DOM 只是投影:这样"点一下到底改了什么"可以直接读 state 验证。
|
|
12
|
+
|
|
13
|
+
import { createReveal } from './reveal.js';
|
|
14
|
+
import { visibleChars } from './filter.js';
|
|
15
|
+
import { snapshotRoster } from './snapshot.js';
|
|
16
|
+
import { readSaved, saveState } from './persist.js';
|
|
17
|
+
import {
|
|
18
|
+
createNameIndex, mergeNameTable, loadRootModels, loadRootNames, buildChars,
|
|
19
|
+
} from './list.js';
|
|
20
|
+
import { el, renderGameRow, renderCharRow, renderVariantRow, applyEntryState } from './rows.js';
|
|
21
|
+
import { formatError } from '../infra/api.js';
|
|
22
|
+
// 名册就绪那句 info 走项目统一的日志通道(与 catalog/bundle 那些 diag 同一出口)
|
|
23
|
+
import { stageLog } from '../app/status.js';
|
|
24
|
+
// 渲染调度(rAF 合批 + 后台标签页兜底)抽到公共层,行为与原先内联实现一致
|
|
25
|
+
import { createScheduler } from '../infra/schedule.js';
|
|
26
|
+
// 容器条目的装载失败记忆:渲染时读出注入行渲染(保持 rows.js 的"纯 DOM 工厂"性质)
|
|
27
|
+
|
|
28
|
+
/** localStorage 键名:不得改名(M7 要用它做持久化,改名会让用户既有状态失效) */
|
|
29
|
+
|
|
30
|
+
/** 渲染上限:每个游戏最多渲染多少角色行(避免 2000+ 角色一次铺满)。
|
|
31
|
+
* 默认值只是兜底,可用 window.__WEB_CONFIG__.renderLimitPerGame 覆盖 */
|
|
32
|
+
const RENDER_LIMIT_DEFAULT = 400;
|
|
33
|
+
function renderLimitPerGame() {
|
|
34
|
+
const custom = Number(window.__WEB_CONFIG__ && window.__WEB_CONFIG__.renderLimitPerGame);
|
|
35
|
+
return Number.isFinite(custom) && custom > 0 ? custom : RENDER_LIMIT_DEFAULT;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 名册状态:games 是按 name 合并后的游戏列表;loaded 按资源根缓存加载结果;
|
|
40
|
+
* gameChars 按游戏缓存跨根归并后的角色;expandedGames/expandedChars 是两级展开键;
|
|
41
|
+
* keyword 是搜索词;selected 是当前选中变体;activeGame 是最后活跃的游戏名。
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** 创建名册视图并挂到容器上;返回对外 API(供 main.js 与自动化验证使用) */
|
|
45
|
+
export function createRosterView({ listEl, countEl, searchEl, collapseBtn, onSelect }) {
|
|
46
|
+
const state = {
|
|
47
|
+
games: [],
|
|
48
|
+
loaded: new Map(),
|
|
49
|
+
gameChars: new Map(),
|
|
50
|
+
expandedGames: new Set(),
|
|
51
|
+
expandedChars: new Set(),
|
|
52
|
+
keyword: '',
|
|
53
|
+
selected: null,
|
|
54
|
+
activeGame: null,
|
|
55
|
+
/**
|
|
56
|
+
* 后台形态探测的进度快照(**本次不做 UI,数据先就绪**)。
|
|
57
|
+
*
|
|
58
|
+
* 将来要加面板/进度条时,**直接读这里,不用再改服务端**:
|
|
59
|
+
* state.probe = { remaining, at, rootId }
|
|
60
|
+
* remaining 剩余待探条目数(服务端 probeRemaining 原值;null = 老服务端没这字段)
|
|
61
|
+
* at 这次取到它的时间戳(性能无关,只用于判断"多久没更新了")
|
|
62
|
+
* rootId 最近一次刷新它的资源根(每列一个根都会刷新,值是**全局**队列深度)
|
|
63
|
+
* 读法示例:`window.__WEB__.state.roster.state.probe.remaining`
|
|
64
|
+
* 更细的计数(runs/probed/unsupported/unknown/skipped/lastBatchMs/lastTrigger)
|
|
65
|
+
* 走 `GET /api/entries/probe`,那才是权威,这里只是"最近一次 catalog 响应顺带回来的"。
|
|
66
|
+
*/
|
|
67
|
+
probe: { remaining: null, at: null, rootId: null },
|
|
68
|
+
};
|
|
69
|
+
// 定位控制器:把"选中后滚到可见"的策略外置(见 roster/reveal.js)
|
|
70
|
+
/**
|
|
71
|
+
* 调试计数器(**纯计量**,只做 ++,不影响任何调度与渲染结果)。
|
|
72
|
+
* 用途:首屏渲染次数是"每个根各触发一次全树 render"这个性能问题的直接指标,
|
|
73
|
+
* 优化前后必须用同一口径对比(见 __WEB__ 侧的 counters() 取数)。
|
|
74
|
+
*/
|
|
75
|
+
const counters = { render: 0, ensureRootLoaded: 0, renderScheduled: 0 };
|
|
76
|
+
|
|
77
|
+
// ------------------------------------------------------------ 名册加载计时(A)
|
|
78
|
+
//
|
|
79
|
+
// 口径(写清是为了以后不要"人均一套秒表"):
|
|
80
|
+
// · 起点 = 第一次调用 ensureRootLoaded(第一个根开始取 catalog)
|
|
81
|
+
// · 终点 = **所有已知根都 settle**(ready 与 error 都算 settle)
|
|
82
|
+
// 为什么把 error 也算:不然一个根失败会让总时长永远不结束,这个数就永远出不来。
|
|
83
|
+
// · 计时一律 performance.now()(单调),不用 Date.now()(会被系统时间校准拉走)。
|
|
84
|
+
// · 不做阈值判断、不认根名:这里只报事实。
|
|
85
|
+
let rosterT0 = null;
|
|
86
|
+
/** rootId → { rootId, ms, count, source, at, error? }(同一根只留最新一次) */
|
|
87
|
+
const rosterPerRoot = new Map();
|
|
88
|
+
/** 「名册就绪」只打一次:之后再有根被补加载(懒加载/搜索)不再重复刷屏 */
|
|
89
|
+
let rosterLogged = false;
|
|
90
|
+
|
|
91
|
+
/** 记一个根的加载结果;若此刻所有已知根都已 settle,打一条 info 日志并定稿 */
|
|
92
|
+
function recordRootLoad(rootId, t0, count, source, error) {
|
|
93
|
+
rosterPerRoot.set(String(rootId), {
|
|
94
|
+
rootId: String(rootId),
|
|
95
|
+
ms: Math.round(performance.now() - t0),
|
|
96
|
+
count: Number(count) || 0,
|
|
97
|
+
source: error ? 'error' : (source || 'built'),
|
|
98
|
+
at: Date.now(),
|
|
99
|
+
...(error ? { error: String(error) } : {}),
|
|
100
|
+
});
|
|
101
|
+
if (rosterLogged) return;
|
|
102
|
+
const all = state.games.flatMap((g) => g.roots.map((r) => String(r.id)));
|
|
103
|
+
if (!all.length || !all.every((id) => rosterPerRoot.has(id))) return;
|
|
104
|
+
rosterLogged = true;
|
|
105
|
+
// 延后一拍再取数:根 settle 的那一刻,调用方(ensureGameLoaded)的
|
|
106
|
+
// 「跨根归并角色」还没跑(它在 await 之后才执行),此刻取 chars 会偏小,
|
|
107
|
+
// 导致日志里的角色数与 rosterStats() 对不上。放到宏任务里取,归并已完成。
|
|
108
|
+
setTimeout(() => {
|
|
109
|
+
const s = rosterStats();
|
|
110
|
+
// 走既有 say/log 通道(与 catalog/bundle 那些 diag 同一出口),文案里不带路径与根名
|
|
111
|
+
stageLog('info', `名册就绪:${s.roots} 根 / ${s.chars} 角色 / ${s.ms} ms`, {
|
|
112
|
+
tag: 'roster', roots: s.roots, entries: s.entries, chars: s.chars, ms: s.ms,
|
|
113
|
+
slowest: s.perRoot.slice().sort((a, b) => b.ms - a.ms).slice(0, 3),
|
|
114
|
+
});
|
|
115
|
+
}, 0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 名册加载统计快照(`window.__WEB__.rosterStats()` 的实现在这里)。
|
|
120
|
+
*
|
|
121
|
+
* 主要用途是**看哪一根慢/失败**:perRoot 按实际加载顺序给出每根的耗时与来源,
|
|
122
|
+
* `source` 的 cache/built 来自服务端 `/api/catalog` 的自报(前端拿不到这个证据),
|
|
123
|
+
* 失败根为 `error` 并带 `error` 文本 —— 失败绝不从表里消失。
|
|
124
|
+
* @returns {{at: number|null, ms: number, roots: number, entries: number, chars: number,
|
|
125
|
+
* perRoot: Array<{rootId: string, ms: number, count: number, source: string, at: number, error?: string}>}}
|
|
126
|
+
*/
|
|
127
|
+
function rosterStats() {
|
|
128
|
+
const perRoot = [...rosterPerRoot.values()].map((r) => ({ ...r }));
|
|
129
|
+
let chars = 0;
|
|
130
|
+
for (const v of state.gameChars.values()) chars += Array.isArray(v && v.chars) ? v.chars.length : 0;
|
|
131
|
+
// 汇总只算**名册自己的根**:perRoot 允许出现额外条目(别的调用方加载过某个根),
|
|
132
|
+
// 但那不属于"名册加载",算进来会让 roots/entries 与左栏数字对不上。
|
|
133
|
+
const named = new Set(state.games.flatMap((g) => g.roots.map((r) => String(r.id))));
|
|
134
|
+
const mine = perRoot.filter((r) => named.has(r.rootId));
|
|
135
|
+
return {
|
|
136
|
+
at: mine.length ? Math.max(...mine.map((r) => r.at)) : null,
|
|
137
|
+
// 全部根还没 settle 时给"到目前为止"的耗时:仍是有意义的读数,不是假值
|
|
138
|
+
ms: rosterT0 === null ? 0 : Math.round(performance.now() - rosterT0),
|
|
139
|
+
roots: named.size,
|
|
140
|
+
entries: mine.reduce((s, r) => s + r.count, 0),
|
|
141
|
+
chars,
|
|
142
|
+
perRoot,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ------------------------------------------------------------ 渲染调度(rAF 合并)
|
|
147
|
+
// 加载阶段每条路径都会触发一次全树重绘(N 个资源根 ≈ 2N+ 次),中间态在同一帧里根本看不到;
|
|
148
|
+
// 合批后既省时间也减少闪烁。三条性质与"为什么"见 infra/schedule.js 的文件头。
|
|
149
|
+
// 这里只保留两样名册专属的东西:跑什么(render)与计量钩子(renderScheduled)。
|
|
150
|
+
const scheduler = createScheduler(() => render(), { onRequest: () => { counters.renderScheduled += 1; } });
|
|
151
|
+
const scheduleRender = scheduler.request;
|
|
152
|
+
const flushRender = scheduler.flush;
|
|
153
|
+
|
|
154
|
+
const reveal = createReveal({ listEl, getState: () => state, render: () => render() });
|
|
155
|
+
|
|
156
|
+
const nameIndex = createNameIndex();
|
|
157
|
+
const namesLoaded = new Set();
|
|
158
|
+
const modelById = new Map();
|
|
159
|
+
|
|
160
|
+
// ------------------------------------------------------------ 数据加载
|
|
161
|
+
|
|
162
|
+
/** 上一次写进调试日志的"剩余待探"数(只用来去重:数值没变就不再记一行) */
|
|
163
|
+
let lastLoggedProbeRemaining = null;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 记录后台形态探测的进度:写进 state.probe(**给将来 UI 读**),并在数值变化时记一行日志。
|
|
167
|
+
*
|
|
168
|
+
* 为什么只在变化时记:一次启动会按根拉九次 catalog,每根都记一行会把调试面板刷满同类信息;
|
|
169
|
+
* 真正有用的是"剩余数发生变化"这个事件。
|
|
170
|
+
* 为什么不做 UI:本轮只要求可见性 + 数据面预留,展示位留待后续。
|
|
171
|
+
* @param {string} rootId 本次刷新它的根(值是全局队列深度,与根无关,只作溯源)
|
|
172
|
+
* @param {number|null} remaining 服务端 probeRemaining 原值;null = 老服务端没该字段
|
|
173
|
+
*/
|
|
174
|
+
function noteProbeProgress(rootId, remaining) {
|
|
175
|
+
if (!Number.isFinite(remaining)) return; // 没这个字段 ⇒ 不更新、不记录(不把未知当 0)
|
|
176
|
+
state.probe = { remaining: Number(remaining), at: Date.now(), rootId };
|
|
177
|
+
if (remaining === lastLoggedProbeRemaining) return;
|
|
178
|
+
lastLoggedProbeRemaining = remaining;
|
|
179
|
+
stageLog('info', `后台形态探测:还剩 ${remaining} 个容器待探(UI 未实现,数据已挂 state.probe)`, {
|
|
180
|
+
root: rootId, remaining,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 确保某个资源根的模型已加载。
|
|
186
|
+
* 并发要点:正在加载中的根必须**可等待**(entry.promise),否则后到的调用方会拿到空数据
|
|
187
|
+
* ——启动恢复与"恢复展开态"的加载会并发进来,这一点决定恢复能否命中。
|
|
188
|
+
* @param {object} root
|
|
189
|
+
* @returns {Promise<object>} 该根的加载状态条目(ready / error)
|
|
190
|
+
*/
|
|
191
|
+
function ensureRootLoaded(root) {
|
|
192
|
+
counters.ensureRootLoaded += 1;
|
|
193
|
+
// 名册计时:从**第一次**请求某根开始,到"所有已知根都 settle"结束。
|
|
194
|
+
// 为什么用 performance.now():单调时钟,系统时间校准不会把耗时拉成负数。
|
|
195
|
+
if (rosterT0 === null) rosterT0 = performance.now();
|
|
196
|
+
const cur = state.loaded.get(root.id);
|
|
197
|
+
if (cur && cur.promise) return cur.promise.then(() => cur);
|
|
198
|
+
if (cur && cur.status === 'ready') return Promise.resolve(cur);
|
|
199
|
+
const entry = { status: 'loading', models: [], source: '', error: null, promise: null };
|
|
200
|
+
const rootT0 = performance.now();
|
|
201
|
+
entry.promise = (async () => {
|
|
202
|
+
try {
|
|
203
|
+
// 名表按根取一次并合并进全局索引(同名表只并一次,避免重复 assign)
|
|
204
|
+
if (!namesLoaded.has(root.id)) {
|
|
205
|
+
namesLoaded.add(root.id);
|
|
206
|
+
const table = await loadRootNames(root.id);
|
|
207
|
+
if (table) mergeNameTable(nameIndex, table);
|
|
208
|
+
}
|
|
209
|
+
const { models, source, serverSource, probeRemaining } = await loadRootModels(root);
|
|
210
|
+
for (const m of models) {
|
|
211
|
+
// 名册持有的就是这个对象本身(buildChars 的 variants[].model 也是它),
|
|
212
|
+
// 所以"写回三字段"必须落在这里 —— 只改 DOM 的话,下一次 render 读的还是旧对象,
|
|
213
|
+
// 表现为"当场变了色、一切换/搜索就掉回中性色"。
|
|
214
|
+
modelById.set(m.id, { model: m, rootId: root.id });
|
|
215
|
+
}
|
|
216
|
+
entry.status = 'ready';
|
|
217
|
+
entry.models = models;
|
|
218
|
+
entry.source = source;
|
|
219
|
+
// 探测进度:顺带记进 state.probe 与调试日志(只在数值变化时记一行,避免刷屏)
|
|
220
|
+
noteProbeProgress(root.id, probeRemaining);
|
|
221
|
+
recordRootLoad(root.id, rootT0, models.length, serverSource || 'built', null);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
entry.status = 'error';
|
|
224
|
+
entry.error = formatError(err);
|
|
225
|
+
// 失败也要进 perRoot(source:'error'):否则"某个根挂了"在计时里凭空消失,
|
|
226
|
+
// 总耗时看起来还挺快 —— 那正是这个功能最该暴露的情形。
|
|
227
|
+
recordRootLoad(root.id, rootT0, 0, 'error', entry.error);
|
|
228
|
+
}
|
|
229
|
+
entry.promise = null;
|
|
230
|
+
scheduleRender(); // 加载完成:合并到下一帧(不再每个根各渲一次)
|
|
231
|
+
})();
|
|
232
|
+
state.loaded.set(root.id, entry);
|
|
233
|
+
scheduleRender(); // 开始加载:同上(视觉上只是把游戏行标成 loading)
|
|
234
|
+
return entry.promise.then(() => entry);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 某游戏的全部根是否都已就绪(有失败也算"可渲染",错误在游戏节点下提示) */
|
|
238
|
+
function gameStatus(game) {
|
|
239
|
+
const entries = game.roots.map((r) => state.loaded.get(r.id));
|
|
240
|
+
if (entries.some((e) => e && e.status === 'loading')) return 'loading';
|
|
241
|
+
if (entries.some((e) => !e || e.status === 'idle')) return 'idle';
|
|
242
|
+
if (entries.every((e) => e && e.status === 'error')) return 'error';
|
|
243
|
+
return 'ready';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 重新归并某游戏的角色(跨根):把该游戏所有已就绪根的模型并成一个数组再归并。
|
|
248
|
+
* 为什么在游戏层归并:同一角色可能同时有 Live2D 与 Spine 变体(来自不同根),
|
|
249
|
+
* 按根各自归并会得到两个同名角色节点,造成不一致。
|
|
250
|
+
* @param {object} game
|
|
251
|
+
*/
|
|
252
|
+
function rebuildGameChars(game) {
|
|
253
|
+
const models = [];
|
|
254
|
+
for (const root of game.roots) {
|
|
255
|
+
const entry = state.loaded.get(root.id);
|
|
256
|
+
if (entry && entry.status === 'ready') models.push(...entry.models);
|
|
257
|
+
}
|
|
258
|
+
const { chars, stats } = buildChars(models, nameIndex, game.name);
|
|
259
|
+
state.gameChars.set(game.key, { chars, stats });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 确保某游戏的模型已加载:先等在飞的根加载完,再补齐未开始的根,最后跨根归并。
|
|
264
|
+
* @param {object} game
|
|
265
|
+
*/
|
|
266
|
+
async function ensureGameLoaded(game) {
|
|
267
|
+
const entries = game.roots.map((r) => state.loaded.get(r.id));
|
|
268
|
+
const inflight = entries.filter((e) => e && e.promise).map((e) => e.promise);
|
|
269
|
+
if (inflight.length) await Promise.all(inflight);
|
|
270
|
+
const pending = game.roots.filter((r) => {
|
|
271
|
+
const e = state.loaded.get(r.id);
|
|
272
|
+
return !e || e.status === 'idle' || e.status === 'error';
|
|
273
|
+
});
|
|
274
|
+
if (pending.length) await Promise.all(pending.map((r) => ensureRootLoaded(r)));
|
|
275
|
+
rebuildGameChars(game);
|
|
276
|
+
scheduleRender();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 搜索需要全局数据:首次输入关键词时把所有游戏补加载(并行,服务端有缓存) */
|
|
280
|
+
async function ensureAllLoaded() {
|
|
281
|
+
await Promise.all(state.games.map((g) => ensureGameLoaded(g)));
|
|
282
|
+
// 硬约束①:所有加载完成后**一定**有一次渲染,不依赖 rAF 是否被节流
|
|
283
|
+
flushRender();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ------------------------------------------------------------ 过滤
|
|
287
|
+
|
|
288
|
+
/** 单条变体的可搜索文本(资源名 / 目录名 / 角色名 / 服装名) */
|
|
289
|
+
// ------------------------------------------------------------ 渲染
|
|
290
|
+
|
|
291
|
+
/** 渲染整棵树(只渲染可见部分;展开的角色才生成变体行) */
|
|
292
|
+
function render() {
|
|
293
|
+
counters.render += 1;
|
|
294
|
+
listEl.replaceChildren();
|
|
295
|
+
|
|
296
|
+
// 首次渲染时插件指纹还没取到 ⇒ 这一帧不显示"不支持"标记(避免用过期结论误导),
|
|
297
|
+
// 指纹就绪后再重画一次。之所以放在 render 里而不是 mount 里:mount 是同步函数。
|
|
298
|
+
// 徽标颜色 / ⚠ / 文案全部来自条目自带的三字段(display/support/loadResult,契约 §一):
|
|
299
|
+
// 名册不再有"本地失败缓存"这一层,也就不需要指纹初始化与快照拉取。
|
|
300
|
+
|
|
301
|
+
const searching = state.keyword.length > 0;
|
|
302
|
+
let totalChars = 0;
|
|
303
|
+
let totalVariants = 0;
|
|
304
|
+
let rendered = 0;
|
|
305
|
+
let loadedGames = 0;
|
|
306
|
+
|
|
307
|
+
for (const game of state.games) {
|
|
308
|
+
const status = gameStatus(game);
|
|
309
|
+
if (status === 'ready') loadedGames += 1;
|
|
310
|
+
const chars = visibleChars(game, state);
|
|
311
|
+
const open = state.expandedGames.has(game.key) || (searching && chars.length > 0);
|
|
312
|
+
|
|
313
|
+
listEl.append(renderGameRow(game, {
|
|
314
|
+
open: state.expandedGames.has(game.key),
|
|
315
|
+
status,
|
|
316
|
+
charCount: chars.length,
|
|
317
|
+
}));
|
|
318
|
+
if (!open) continue;
|
|
319
|
+
|
|
320
|
+
if (status === 'loading') {
|
|
321
|
+
listEl.append(el('p', 'muted-hint tree-hint', '加载中…'));
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (status === 'error') {
|
|
325
|
+
listEl.append(el('p', 'error-banner', `加载失败:${game.roots.map((r) => r.id).join(', ')}`));
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
totalChars += chars.length;
|
|
330
|
+
const limit = renderLimitPerGame(); // 每次渲染读一次:改配置后刷新即生效
|
|
331
|
+
const selectedId = state.selected ? state.selected.modelId : null;
|
|
332
|
+
for (const c of chars) {
|
|
333
|
+
if (rendered >= limit) break;
|
|
334
|
+
rendered += 1;
|
|
335
|
+
const charOpen = state.expandedChars.has(c.key) || searching;
|
|
336
|
+
listEl.append(renderCharRow(game, c, charOpen));
|
|
337
|
+
// 变体数按数据统计(不依赖是否展开),与「N 游戏 / M 角色 / K 变体」口径一致
|
|
338
|
+
totalVariants += c.variants.length;
|
|
339
|
+
if (!charOpen) continue;
|
|
340
|
+
for (const v of c.variants) {
|
|
341
|
+
// 行内的颜色 / ⚠ / 文案全部由条目自带的三字段渲染(display/support/loadResult),
|
|
342
|
+
// 这里不再注入任何本地失败记录 —— 服务端是唯一权威(契约 §一)。
|
|
343
|
+
listEl.append(renderVariantRow(game, c, v, selectedId));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (!chars.length) {
|
|
347
|
+
listEl.append(el('p', 'muted-hint tree-hint', searching ? '无匹配' : '该游戏没有可加载模型'));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// 计数文案:全量已加载时用统一口径(N 游戏 / M 角色 / K 变体),否则标注已加载多少
|
|
352
|
+
const allLoaded = loadedGames === state.games.length;
|
|
353
|
+
const prefix = allLoaded ? `${state.games.length} 游戏` : `${state.games.length} 游戏 / 已加载 ${loadedGames}`;
|
|
354
|
+
countEl.textContent = searching
|
|
355
|
+
? `搜索「${state.keyword}」:${totalChars} 角色 / ${totalVariants} 变体`
|
|
356
|
+
: `${prefix}:${totalChars} 角色 / ${totalVariants} 变体`;
|
|
357
|
+
|
|
358
|
+
// 供自动化断言:把状态投影成可读快照
|
|
359
|
+
document.documentElement.dataset.rosterReady = '1';
|
|
360
|
+
document.documentElement.dataset.rosterSnapshot = JSON.stringify(snapshot());
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** 状态快照(自动化断言与排查用,避免每次都去数 DOM) */
|
|
364
|
+
/** 快照:投影逻辑在 roster/snapshot.js(断言只读这份) */
|
|
365
|
+
const snapshot = () => snapshotRoster({ state, listEl, gameStatus });
|
|
366
|
+
|
|
367
|
+
// ------------------------------------------------------------ 事件
|
|
368
|
+
|
|
369
|
+
/** 点一级:折叠/展开游戏(首次展开触发该游戏所有根的数据加载) */
|
|
370
|
+
async function onGameClick(gameKey) {
|
|
371
|
+
if (state.expandedGames.has(gameKey)) {
|
|
372
|
+
state.expandedGames.delete(gameKey);
|
|
373
|
+
render();
|
|
374
|
+
saveState(state);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
state.expandedGames.add(gameKey);
|
|
378
|
+
// 先渲染一次:已加载过的游戏直接出角色行(ensureGameLoaded 在无待加载根时不会单独渲染)
|
|
379
|
+
render();
|
|
380
|
+
const game = state.games.find((g) => g.key === gameKey);
|
|
381
|
+
if (game) await ensureGameLoaded(game);
|
|
382
|
+
saveState(state);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** 点二级:折叠/展开角色(变体已在内存,不发请求) */
|
|
386
|
+
function onCharClick(charKey) {
|
|
387
|
+
if (state.expandedChars.has(charKey)) state.expandedChars.delete(charKey);
|
|
388
|
+
else state.expandedChars.add(charKey);
|
|
389
|
+
render();
|
|
390
|
+
saveState(state);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** 点三级:选中变体(高亮 + 记录状态,M4 用它去渲染) */
|
|
394
|
+
function onVariantClick(modelId, gameKey, charKey) {
|
|
395
|
+
const game = state.games.find((g) => g.key === gameKey);
|
|
396
|
+
state.selected = { modelId, gameKey, gameName: game ? game.name : null, charKey };
|
|
397
|
+
state.activeGame = game ? game.name : state.activeGame;
|
|
398
|
+
render();
|
|
399
|
+
state.lastReveal = reveal.revealActive(); // 选中后把这一行滚到可视区(已可见/用户正在滚都会跳过)
|
|
400
|
+
saveState(state);
|
|
401
|
+
if (typeof onSelect === 'function') {
|
|
402
|
+
const hit = modelById.get(modelId);
|
|
403
|
+
onSelect(hit ? hit.model : null);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** 事件委托:树行是动态生成的,统一在容器上监听一次 */
|
|
408
|
+
function bindEvents() {
|
|
409
|
+
/** 按行的类型分派到对应动作(鼠标与键盘共用,避免两套逻辑走偏) */
|
|
410
|
+
function activate(row) {
|
|
411
|
+
if (row.dataset.modelId) {
|
|
412
|
+
onVariantClick(row.dataset.modelId, row.dataset.gameKey, row.dataset.charKey);
|
|
413
|
+
} else if (row.dataset.charKey) {
|
|
414
|
+
onCharClick(row.dataset.charKey);
|
|
415
|
+
} else if (row.dataset.gameKey) {
|
|
416
|
+
onGameClick(row.dataset.gameKey);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 用户滚动信号由 reveal 控制器统一挂(滚轮 / 触摸 / 指针 / 滚动条)
|
|
421
|
+
reveal.attachUserScrollListeners();
|
|
422
|
+
|
|
423
|
+
listEl.addEventListener('click', (e) => {
|
|
424
|
+
const row = e.target.closest('[data-model-id], [data-char-key], [data-game-key]');
|
|
425
|
+
if (row) activate(row);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// 键盘可达:树行带 tabindex="0",Enter / 空格与点击等价
|
|
429
|
+
listEl.addEventListener('keydown', (e) => {
|
|
430
|
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
431
|
+
const row = e.target.closest('[data-model-id], [data-char-key], [data-game-key]');
|
|
432
|
+
if (!row) return;
|
|
433
|
+
e.preventDefault();
|
|
434
|
+
activate(row);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
searchEl.addEventListener('input', () => {
|
|
438
|
+
state.keyword = searchEl.value.trim().toLowerCase();
|
|
439
|
+
// 搜索要全局数据:首次输入时把所有游戏补上(之后都走缓存,不再请求)
|
|
440
|
+
if (state.keyword && [...state.gameChars.keys()].length < state.games.length) {
|
|
441
|
+
ensureAllLoaded();
|
|
442
|
+
} else {
|
|
443
|
+
render();
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// 「收起」:只清空两级展开状态,面板保留
|
|
448
|
+
collapseBtn.addEventListener('click', () => {
|
|
449
|
+
state.expandedGames.clear();
|
|
450
|
+
state.expandedChars.clear();
|
|
451
|
+
render();
|
|
452
|
+
saveState(state);
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* 初始化:把资源根按 name 合并成游戏 → 渲染(不加载任何模型,等用户展开)
|
|
458
|
+
* @param {object[]} roots /api/roots 的 roots
|
|
459
|
+
* @param {{restoreExpanded?: boolean}} opts restoreExpanded=false 时不恢复任何展开态(自动恢复被关闭时用)
|
|
460
|
+
*/
|
|
461
|
+
function mount(roots, opts) {
|
|
462
|
+
const restoreExpanded = !opts || opts.restoreExpanded !== false;
|
|
463
|
+
// 同名根合并成一个游戏节点(照 char-tree.js:name 是归一 key)
|
|
464
|
+
const byName = new Map();
|
|
465
|
+
for (const root of roots) {
|
|
466
|
+
const name = root.name || root.id;
|
|
467
|
+
if (!byName.has(name)) byName.set(name, { key: `game:${name}`, name, roots: [] });
|
|
468
|
+
byName.get(name).roots.push(root);
|
|
469
|
+
}
|
|
470
|
+
state.games = [...byName.values()];
|
|
471
|
+
|
|
472
|
+
const saved = readSaved();
|
|
473
|
+
if (restoreExpanded) {
|
|
474
|
+
// 展开状态恢复:只恢复仍存在的游戏,避免历史配置残留导致空节点
|
|
475
|
+
const gameKeys = new Set(state.games.map((g) => g.key));
|
|
476
|
+
for (const k of saved.games) if (gameKeys.has(k)) state.expandedGames.add(k);
|
|
477
|
+
for (const c of saved.chars) state.expandedChars.add(c);
|
|
478
|
+
state.activeGame = saved.activeGame;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
bindEvents();
|
|
482
|
+
render();
|
|
483
|
+
// 恢复展开的游戏要补加载数据(用户上次就是展开的)
|
|
484
|
+
if (restoreExpanded) {
|
|
485
|
+
for (const key of [...state.expandedGames]) {
|
|
486
|
+
const game = state.games.find((g) => g.key === key);
|
|
487
|
+
if (game) ensureGameLoaded(game);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** 从存档里取出"上次选中的变体 id":优先按活跃游戏取,其次旧格式单个 id,最后取任一非空值 */
|
|
493
|
+
function targetModelId(saved) {
|
|
494
|
+
const byGame = saved.lastByGame || {};
|
|
495
|
+
if (saved.activeGame && byGame[saved.activeGame]) return byGame[saved.activeGame];
|
|
496
|
+
if (saved.lastModelId) return saved.lastModelId;
|
|
497
|
+
const first = Object.values(byGame).find((v) => typeof v === 'string' && v);
|
|
498
|
+
return first || null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** 在某游戏已加载的角色里找该变体,命中返回 { charKey, variant }(变体对象形如 { model, nm }) */
|
|
502
|
+
function findVariant(game, modelId) {
|
|
503
|
+
const entry = state.gameChars.get(game.key);
|
|
504
|
+
if (!entry) return null;
|
|
505
|
+
for (const c of entry.chars) {
|
|
506
|
+
const v = c.variants.find((x) => x.model && x.model.id === modelId);
|
|
507
|
+
if (v) return { charKey: c.key, variant: v };
|
|
508
|
+
}
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* 把左栏树**从上到下**摊平成变体序列(翻页的池子就是它)。
|
|
514
|
+
*
|
|
515
|
+
* 为什么不让外部自己请求接口:树的"游戏"节点是**同名根合并**出来的
|
|
516
|
+
* (例如「碧蓝航线」= azurlane + azurlane_spine + azurlane_bundle 三个根),
|
|
517
|
+
* 外部按单个 rootId 取清单会漏掉另外两个根 ⇒ 表现为"只能在 live2d 之间翻,翻不到 spine"。
|
|
518
|
+
* 用树自己的数据结构(含合并、含排序)作唯一真相源,顺序天然与用户看到的一致。
|
|
519
|
+
* @returns {Promise<Array<{model:object, gameKey:string, charKey:string}>>}
|
|
520
|
+
*/
|
|
521
|
+
async function listVariants() {
|
|
522
|
+
const out = [];
|
|
523
|
+
for (const game of state.games) {
|
|
524
|
+
if (state.expandedGames && !state.expandedGames.size) { /* 未展开也要能翻:下面统一加载 */ }
|
|
525
|
+
// 目标游戏可能还没加载(用户没展开过):按需加载一次,保证序列完整
|
|
526
|
+
if (!state.gameChars.get(game.key)) {
|
|
527
|
+
try { await ensureGameLoaded(game); } catch { continue; }
|
|
528
|
+
}
|
|
529
|
+
const entry = state.gameChars.get(game.key);
|
|
530
|
+
if (!entry) continue;
|
|
531
|
+
for (const c of entry.chars) {
|
|
532
|
+
for (const v of c.variants) {
|
|
533
|
+
if (v && v.model && v.model.id) out.push({ model: v.model, gameKey: game.key, charKey: c.key });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return out;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* 取某个变体的**兄弟变体**(同一角色/同一 charKey 下的全部变体)。
|
|
542
|
+
* @param {string} modelId
|
|
543
|
+
* @returns {Promise<Array<{model:object, gameKey:string, charKey:string}>>}
|
|
544
|
+
*/
|
|
545
|
+
async function variantsOf(modelId) {
|
|
546
|
+
for (const game of state.games) {
|
|
547
|
+
if (!state.gameChars.get(game.key)) {
|
|
548
|
+
try { await ensureGameLoaded(game); } catch { continue; }
|
|
549
|
+
}
|
|
550
|
+
const entry = state.gameChars.get(game.key);
|
|
551
|
+
if (!entry) continue;
|
|
552
|
+
for (const c of entry.chars) {
|
|
553
|
+
const hit = c.variants.some((v) => v && v.model && v.model.id === modelId);
|
|
554
|
+
if (hit) return c.variants.filter((v) => v && v.model).map((v) => ({ model: v.model, gameKey: game.key, charKey: c.key }));
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return [];
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* 按 modelId 选中某个变体并同步左栏(**外部入口专用**)。
|
|
562
|
+
*
|
|
563
|
+
* 为什么需要它:左栏点击时选中态由 onVariantClick 自己维护,但"上一个/下一个"、快捷键等
|
|
564
|
+
* 外部入口只调装载、不碰左栏 ⇒ 树上高亮停在上一个模型,看起来像没切过去。
|
|
565
|
+
* 这里把"选中态 + 展开 + 渲染 + 定位 + 存盘"收成一处,外部入口只调它。
|
|
566
|
+
* 不触发 onSelect:装载由调用方的统一入口负责,避免同一次切换装载两遍。
|
|
567
|
+
* @param {string} modelId
|
|
568
|
+
* @param {{reveal?: boolean}} [opts] reveal=false 时不滚动定位(默认滚到可视区)
|
|
569
|
+
* @returns {Promise<{ok:boolean, gameKey?:string, charKey?:string, reason?:string}>}
|
|
570
|
+
*/
|
|
571
|
+
async function selectById(modelId, opts = {}) {
|
|
572
|
+
if (!modelId) return { ok: false, reason: 'no-id' };
|
|
573
|
+
for (const game of state.games) {
|
|
574
|
+
// 目标可能属于还没展开/加载的游戏:按需加载一次(与启动恢复同策略,不预取全部)
|
|
575
|
+
if (!state.gameChars.get(game.key)) {
|
|
576
|
+
try { await ensureGameLoaded(game); } catch { continue; }
|
|
577
|
+
}
|
|
578
|
+
const hit = findVariant(game, modelId);
|
|
579
|
+
if (!hit) continue;
|
|
580
|
+
state.selected = { modelId, gameKey: game.key, gameName: game.name, charKey: hit.charKey };
|
|
581
|
+
state.activeGame = game.name;
|
|
582
|
+
// 不展开的话目标行根本没渲染出来,高亮也就无从显示
|
|
583
|
+
state.expandedGames.add(game.key);
|
|
584
|
+
state.expandedChars.add(hit.charKey);
|
|
585
|
+
render();
|
|
586
|
+
if (opts.reveal !== false) state.lastReveal = reveal.revealActive();
|
|
587
|
+
saveState(state);
|
|
588
|
+
return { ok: true, gameKey: game.key, charKey: hit.charKey };
|
|
589
|
+
}
|
|
590
|
+
return { ok: false, reason: 'not-found' };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* 启动恢复:按存档把上次选中的变体重新选中并渲染。
|
|
595
|
+
* 懒加载顺序:目标变体所属游戏可能还没加载 ⇒ 先按游戏逐个加载再看命中,不预取全部。
|
|
596
|
+
* 失效兜底:所有游戏都找过仍没有 ⇒ 安静回落空舞台(不展开、不报错、不留半选中状态)。
|
|
597
|
+
* @returns {Promise<{restored: boolean, modelId?: string, reason?: string}>}
|
|
598
|
+
*/
|
|
599
|
+
async function restoreLast() {
|
|
600
|
+
const saved = readSaved();
|
|
601
|
+
const modelId = targetModelId(saved);
|
|
602
|
+
if (!modelId) return { restored: false, reason: 'no-saved' };
|
|
603
|
+
|
|
604
|
+
// 活跃游戏先试(多数命中在第一个),再按顺序找其余游戏
|
|
605
|
+
const ordered = [...state.games];
|
|
606
|
+
const active = ordered.find((g) => g.name === saved.activeGame);
|
|
607
|
+
if (active) ordered.unshift(...ordered.splice(ordered.indexOf(active), 1));
|
|
608
|
+
|
|
609
|
+
for (const game of ordered) {
|
|
610
|
+
await ensureGameLoaded(game);
|
|
611
|
+
const hit = findVariant(game, modelId);
|
|
612
|
+
if (!hit) continue;
|
|
613
|
+
state.expandedGames.add(game.key);
|
|
614
|
+
state.expandedChars.add(hit.charKey);
|
|
615
|
+
state.selected = { modelId, gameKey: game.key, gameName: game.name, charKey: hit.charKey };
|
|
616
|
+
state.activeGame = game.name;
|
|
617
|
+
render();
|
|
618
|
+
// 自动恢复同样把恢复到的那一行滚进视野;但用户若正在滚动就不会抢(守卫在 revealActive 里)
|
|
619
|
+
state.lastReveal = reveal.revealActive();
|
|
620
|
+
saveState(state); // 顺带把旧格式迁移成 lastByGame(saveState 只写 lastByGame)
|
|
621
|
+
if (typeof onSelect === 'function') onSelect(modelById.get(modelId)?.model || null);
|
|
622
|
+
return { restored: true, modelId, gameKey: game.key, charKey: hit.charKey };
|
|
623
|
+
}
|
|
624
|
+
// 存档里的模型已不存在:清掉半状态,保持空舞台
|
|
625
|
+
state.selected = null;
|
|
626
|
+
render();
|
|
627
|
+
return { restored: false, reason: 'missing', modelId };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* 把服务端的三字段写回名册条目并就地重画那一行(契约 §一 INV-D1 的前端落点)。
|
|
632
|
+
*
|
|
633
|
+
* 为什么"写回对象"和"刷 DOM"两件事都要做(只刷 DOM 就是之前那个 bug):
|
|
634
|
+
* DOM 是一次性快照,`render()` 每次都用条目对象重画该行。
|
|
635
|
+
* 实测过的错位:装载成功后只改了 DOM ⇒ 当场看着对了,
|
|
636
|
+
* 随后任意一次重渲染(切换模型 / 搜索 / 展开折叠)又把旧值画回来 ⇒ "马上变回中性色"。
|
|
637
|
+
* 所以权威写回必须落在**名册持有的那个对象**上(modelById 与 buildChars 的
|
|
638
|
+
* variants[].model 是同一引用,改它两边都生效)。
|
|
639
|
+
* @param {string} modelId 条目 id
|
|
640
|
+
* @param {{display?:object, support?:object, loadResult?:object}|null} entryState 服务端三字段
|
|
641
|
+
* @returns {boolean} 条目不在名册里返回 false(调用方可忽略)
|
|
642
|
+
*/
|
|
643
|
+
function setEntryState(modelId, entryState) {
|
|
644
|
+
const hit = modelById.get(String(modelId || ''));
|
|
645
|
+
if (!hit || !hit.model) return false;
|
|
646
|
+
const st = entryState || {};
|
|
647
|
+
hit.model.display = st.display || { accent: null, provider: null, candidates: [] };
|
|
648
|
+
hit.model.support = st.support || { state: 'unknown', detail: null };
|
|
649
|
+
hit.model.loadResult = st.loadResult || null;
|
|
650
|
+
// 对象已更新 ⇒ 只重画这一行(不整表重建,保留展开态/选中态/滚动位置)
|
|
651
|
+
try { applyEntryState(hit.model, st); } catch { /* 单行重画失败不影响数据 */ }
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
mount, state, snapshot, render, ensureGameLoaded, selectById, listVariants, variantsOf,
|
|
657
|
+
setEntryState,
|
|
658
|
+
restoreLast,
|
|
659
|
+
revealActive: reveal.revealActive,
|
|
660
|
+
markUserScroll: reveal.markUserScroll,
|
|
661
|
+
/** 调试计数(渲染/加载次数):优化前后用同一口径对比 */
|
|
662
|
+
counters: () => ({ ...counters }),
|
|
663
|
+
/** 名册加载计时快照(`window.__WEB__.rosterStats()` 用它;口径见文件内"名册加载计时"注释) */
|
|
664
|
+
rosterStats,
|
|
665
|
+
/** 立刻落地待渲染的请求(测试/自检用:不依赖 rAF 时机) */
|
|
666
|
+
flushRender,
|
|
667
|
+
/** 归零计数(测量前调用,避免把上一段的调用混进来) */
|
|
668
|
+
resetCounters: () => { counters.render = 0; counters.ensureRootLoaded = 0; counters.renderScheduled = 0; },
|
|
669
|
+
};
|
|
670
|
+
}
|