modelstage 0.5.24 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +2 -56
  3. package/bin/modelstage.mjs +48 -12
  4. package/config/models/live2d.assetbundls.json +1 -1
  5. package/config/models/live2d.azurlane.json +1 -1
  6. package/config/models/live2d.azurlane_bundle.json +1 -1
  7. package/config/models/live2d.azurlane_bundle.jsonl +2 -0
  8. package/config/models/live2d.azurlane_spine.json +1 -1
  9. package/config/models/live2d.bd2.json +1 -1
  10. package/config/models/live2d.bd2.jsonl +2 -0
  11. package/config/models/live2d.bluearchive.json +1 -1
  12. package/config/models/live2d.nikke.json +1 -1
  13. package/config/models/live2d.nikke.jsonl +2 -0
  14. package/config/models/live2d.nikke_2.json +1 -1
  15. package/config/models/live2d.npc.json +1 -1
  16. package/config/models/spine.assetbundls.json +1 -1
  17. package/config/models/spine.azurlane.json +1 -1
  18. package/config/models/spine.azurlane_bundle.json +1 -1
  19. package/config/models/spine.azurlane_bundle.jsonl +2 -0
  20. package/config/models/spine.azurlane_spine.json +1 -1
  21. package/config/models/spine.bd2.json +1 -1
  22. package/config/models/spine.bd2.jsonl +433 -0
  23. package/config/models/spine.bluearchive.json +1 -1
  24. package/config/models/spine.nikke.json +1 -1
  25. package/config/models/spine.nikke_2.json +1 -1
  26. package/config/models/spine.npc.json +1 -1
  27. package/package.json +8 -2
  28. package/src/server/assets/probe.mjs +1 -1
  29. package/src/server/core/config.mjs +2 -2
  30. package/src/server/core/log.mjs +1 -1
  31. package/src/server/data/catalog.mjs +11 -14
  32. package/src/server/data/live2d.mjs +139 -40
  33. package/src/server/data/manifest-cache.mjs +53 -10
  34. package/src/server/data/manifest-jsonl.mjs +264 -0
  35. package/src/server/data/manifest-stamp.mjs +52 -2
  36. package/src/server/data/scan-progress.mjs +305 -0
  37. package/src/server/data/scan-queue.mjs +99 -0
  38. package/src/server/data/spine.mjs +324 -106
  39. package/src/server/http/write.mjs +8 -1
  40. package/src/server/plugins/registry.mjs +1 -1
  41. package/src/server/routes/data.mjs +76 -17
  42. package/src/server/serve.mjs +71 -4
  43. package/src/server/unpack/plugin-store.mjs +1 -1
  44. package/src/server/unpack/runtime/exec.mjs +37 -8
  45. package/src/server/unpack/worker-client.mjs +23 -8
  46. package/src/shared/scan-limits.mjs +23 -0
  47. package/src/shared/type-labels.mjs +31 -0
  48. package/src/web/app/anim-list.js +1 -1
  49. package/src/web/app/backgrounds.js +4 -8
  50. package/src/web/app/dir-picker.js +3 -5
  51. package/src/web/app/memory-budget.js +0 -1
  52. package/src/web/app/memory-guard.js +0 -1
  53. package/src/web/app/model-actions.js +18 -15
  54. package/src/web/app/root-manager.js +2 -4
  55. package/src/web/app/transport-actions.js +1 -1
  56. package/src/web/app/ui-state.js +1 -1
  57. package/src/web/components.css +149 -0
  58. package/src/web/debug/race-probe.js +36 -13
  59. package/src/web/engine/textures.js +1 -2
  60. package/src/web/index.html +2 -0
  61. package/src/web/infra/api.js +10 -19
  62. package/src/web/infra/document.js +3 -3
  63. package/src/web/infra/plugin-host-unpack.mjs +81 -9
  64. package/src/web/infra/ready.js +177 -39
  65. package/src/web/live2d/bridge.js +1 -1
  66. package/src/web/live2d/runtime-adapter.js +2 -4
  67. package/src/web/panel/panel-settings.js +88 -3
  68. package/src/web/roster/events.js +159 -0
  69. package/src/web/roster/filter.js +4 -4
  70. package/src/web/roster/list.js +3 -3
  71. package/src/web/roster/load.js +440 -0
  72. package/src/web/roster/persist.js +64 -15
  73. package/src/web/roster/render.js +284 -0
  74. package/src/web/roster/reveal.js +4 -9
  75. package/src/web/roster/rows.js +35 -13
  76. package/src/web/roster/scan-stream.js +346 -0
  77. package/src/web/roster/snapshot.js +16 -8
  78. package/src/web/roster/store.js +292 -0
  79. package/src/web/roster/type-filter.js +97 -0
  80. package/src/web/roster/view.js +148 -544
  81. package/src/web/spine/canvas-runtime.js +4 -5
  82. package/src/web/spine/runtimes.js +1 -1
  83. package/tool/lib/check-perms.mjs +0 -114
  84. package/tool/lib/contract-checks.mjs +0 -582
  85. package/tool/lib/cubism-core.mjs +0 -122
  86. package/tool/lib/export-bundle.mjs +0 -328
  87. package/tool/lib/spine-runtime.mjs +0 -131
  88. package/tool/lib/test-lock.mjs +0 -178
  89. package/tool/lib/test-report.mjs +0 -133
@@ -1,506 +1,119 @@
1
- // roster-view.js —— 名册视图层:三级树(游戏 → 角色 → 变体)+ 搜索 + 选中态(M2)
1
+ // roster-view.js —— 名册视图层(M2):三级树(游戏 → 角色 → 变体)+ 搜索 + 选中态
2
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 验证。
3
+ // 本文件只做**装配**(拆分后):store.js = 名册唯一状态持有者;load.js = 加载编排;render.js = 渲染;
4
+ // events.js = DOM 事件。依赖方向(单向,无环):store ← load ← render/events ← view。
5
+ // 产品行为(与拆分前一致):① 一级按资源根 name 合并成游戏节点(同名根合并,展开时才请求模型);
6
+ // ② 二级角色跨根归并(同一角色多根模型归到一个节点,不是把各根的行拼起来);
7
+ // ③ 三级变体带类型标签(Spine / Live2D / 资源包)与来源根 id;④ 搜索是前端过滤,命中即展开到变体层;
8
+ // ⑤ 选中态写 localStorage(键名沿用既有,字段用 group 口径);⑥ 顶部「收起」只清空展开状态,面板本身保留。
12
9
 
13
10
  import { createReveal } from './reveal.js';
14
- import { visibleChars } from './filter.js';
15
11
  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 合批 + 后台标签页兜底)抽到公共层,行为与原先内联实现一致
12
+ import { createRosterStore } from './store.js';
13
+ import { readSaved, saveState, readLastModel, saveLastModel } from './persist.js';
14
+ import { createNameIndex, buildChars } from './list.js';
15
+ import { applyEntryState } from './rows.js';
25
16
  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
- */
17
+ import { createRosterLoad } from './load.js';
18
+ import { createRosterRender } from './render.js';
19
+ import { createRosterEvents } from './events.js';
43
20
 
44
21
  /** 创建名册视图并挂到容器上;返回对外 API(供 main.js 与自动化验证使用) */
45
22
  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;
23
+ // 类型胶囊行:容器由 index.html 提供(#roster-type-filter);取不到(旧页面/单测)就整体降级为"没有类型筛选"
24
+ const typeFilterEl = typeof document !== 'undefined' ? document.getElementById('roster-type-filter') : null;
90
25
 
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
- }
26
+ /** 调试计数器(纯计量,只做 ++):首屏渲染次数是"每个根各触发一次全树 render"的直接指标,优化前后同口径对比 */
27
+ const counters = { render: 0, ensureRootLoaded: 0, renderScheduled: 0 };
145
28
 
146
- // ------------------------------------------------------------ 渲染调度(rAF 合并)
147
- // 加载阶段每条路径都会触发一次全树重绘(N 个资源根 ≈ 2N+ 次),中间态在同一帧里根本看不到;
148
- // 合批后既省时间也减少闪烁。三条性质与"为什么"见 infra/schedule.js 的文件头。
149
- // 这里只保留两样名册专属的东西:跑什么(render)与计量钩子(renderScheduled)。
29
+ // 渲染调度(rAF 合批 + 后台标签页兜底):三条性质与"为什么"见 infra/schedule.js 文件头
150
30
  const scheduler = createScheduler(() => render(), { onRequest: () => { counters.renderScheduled += 1; } });
151
31
  const scheduleRender = scheduler.request;
152
32
  const flushRender = scheduler.flush;
153
33
 
34
+ // 名表索引(跨根共用一份):store 写完 models 要自动重算角色树,归并时也要用它 ⇒ 先于 store 建
35
+ const nameIndex = createNameIndex();
36
+ // 名册状态由 store 统一持有(**唯一可写者**);view 只读 state、通过动作改
37
+ const store = createRosterStore({ buildChars, nameIndex, onChange: () => scheduleRender() });
38
+ const state = store.state;
39
+ // 定位控制器:把"选中后滚到可见"的策略外置(见 roster/reveal.js)
154
40
  const reveal = createReveal({ listEl, getState: () => state, render: () => render() });
155
41
 
156
- const nameIndex = createNameIndex();
42
+ // 名册持有的模型对象(渲染与选中都读它,跨模块共用同一份引用)
157
43
  const namesLoaded = new Set();
158
44
  const modelById = new Map();
159
45
 
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
- }
46
+ // ------------------------------------------------------------ 模块装配
47
+ // store/reveal 只持有"延迟调用"的引用(() => render()),能写在渲染模块前面;render 用函数声明避免 TDZ。
48
+ // render 转调渲染模块(四处装配行都通过它拿最新实现);snapshot 的投影逻辑在 roster/snapshot.js。
49
+ function render() { return renderModule.render(); }
50
+ function snapshot() { return snapshotRoster({ state, listEl, groupStatus: renderModule.groupStatus }); }
51
+
52
+ // 队列位次由 load 提供,但 render 要先建出来 ⇒ 用一层转发,避免两个工厂互相引用
53
+ let queuePositionOfRef = () => 0;
54
+ const renderModule = createRosterRender({
55
+ store, counters, listEl, countEl, typeFilterEl,
56
+ snapshotOf: () => snapshot(),
57
+ queuePositionOf: (group) => queuePositionOfRef(group),
58
+ });
59
+
60
+ const load = createRosterLoad({
61
+ store, counters, nameIndex, namesLoaded, modelById,
62
+ render, scheduleRender, flushRender,
63
+ rebuildGroupChars: renderModule.rebuildGroupChars,
64
+ aggregateScan: renderModule.aggregateScan,
65
+ applyScanStatusToRow: renderModule.applyScanStatusToRow,
66
+ });
67
+ queuePositionOfRef = load.queuePositionOf;
68
+
69
+ const events = createRosterEvents({
70
+ store, listEl, searchEl, collapseBtn, typeFilterEl, reveal, render, load, modelById, onSelect,
71
+ });
455
72
 
456
73
  /**
457
74
  * 初始化:把资源根按 name 合并成游戏 → 渲染(不加载任何模型,等用户展开)
458
- * @param {object[]} roots /api/roots 的 roots
459
- * @param {{restoreExpanded?: boolean}} opts restoreExpanded=false 时不恢复任何展开态(自动恢复被关闭时用)
75
+ * @param {{restoreExpanded?: boolean}} opts restoreExpanded=false 时不恢复任何展开态
460
76
  */
461
77
  function mount(roots, opts) {
462
78
  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
-
79
+ const skipRestore = !!(opts && opts.skipRestore);
80
+ store.setTypeFilter([]); // 类型筛选不持久化:每次打开都是不筛选(当次会话内的临时动作)
81
+ store.setRoots(roots); // 同名根合并成游戏节点:规则收在 store.setRoots
472
82
  const saved = readSaved();
473
83
  if (restoreExpanded) {
474
84
  // 展开状态恢复:只恢复仍存在的游戏,避免历史配置残留导致空节点
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;
85
+ store.restoreExpanded({ groups: saved.groups, chars: saved.chars });
86
+ store.setActiveGroup(saved.activeGroup); // 存储字段与内存同名(activeGroup)
479
87
  }
480
-
481
- bindEvents();
88
+ events.bindEvents();
482
89
  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);
90
+ // 启动后:上次所用模型所在的那一个根**优先**加载(restoreLast),其余分组排进队列按顺序串行加载。
91
+ // 不再需要用户点「点击加载」:队列会自己往下走,行上只显示"排队中(第 N 位)/ 加载中"。
92
+ // 串行而不是并行,是因为服务端扫描是重活,并发会互相抢 IO 把整站拖慢。
93
+ // **队列必须等首个模型装载落地再放行**:restoreLast 里 await 的是"根清单 + 装载完成",
94
+ // 真正确认能出画才算 settle;否则队列一开始扫别的根,就把首模型的资源请求挤到后面
95
+ // (现象就是"列表全加载完,模型才出来")。失败同样放行,避免一个坏根把队列卡死。
96
+ // **返回这个 promise**:调用方(loadRoots)要拿恢复结果,不要再自己调一次 restoreLast。
97
+ return (async () => {
98
+ let info = { restored: false, reason: skipRestore ? 'skipped' : 'no-pointer' };
99
+ if (!skipRestore) {
100
+ try { info = await restoreLast(); } catch { /* 恢复失败不影响队列 */ }
488
101
  }
489
- }
102
+ load.enqueueRemaining();
103
+ load.startQueue();
104
+ return info;
105
+ })();
490
106
  }
491
107
 
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;
108
+ /** 某组是否已归并出角色(判据是"真有 chars",不是"根有 models") */
109
+ function hasGroupChars(group) {
110
+ const entry = state.groupChars.get(group.key);
111
+ return !!(entry && Array.isArray(entry.chars) && entry.chars.length);
499
112
  }
500
113
 
501
114
  /** 在某游戏已加载的角色里找该变体,命中返回 { charKey, variant }(变体对象形如 { model, nm }) */
502
- function findVariant(game, modelId) {
503
- const entry = state.gameChars.get(game.key);
115
+ function findVariant(group, modelId) {
116
+ const entry = state.groupChars.get(group.key);
504
117
  if (!entry) return null;
505
118
  for (const c of entry.chars) {
506
119
  const v = c.variants.find((x) => x.model && x.model.id === modelId);
@@ -510,27 +123,23 @@ export function createRosterView({ listEl, countEl, searchEl, collapseBtn, onSel
510
123
  }
511
124
 
512
125
  /**
513
- * 把左栏树**从上到下**摊平成变体序列(翻页的池子就是它)。
514
- *
515
- * 为什么不让外部自己请求接口:树的"游戏"节点是**同名根合并**出来的
516
- * (例如「碧蓝航线」= azurlane + azurlane_spine + azurlane_bundle 三个根),
517
- * 外部按单个 rootId 取清单会漏掉另外两个根 ⇒ 表现为"只能在 live2d 之间翻,翻不到 spine"。
518
- * 用树自己的数据结构(含合并、含排序)作唯一真相源,顺序天然与用户看到的一致。
519
- * @returns {Promise<Array<{model:object, gameKey:string, charKey:string}>>}
126
+ * 把左栏树**从上到下**摊平成变体序列(翻页的池子就是它)。用树自己的数据结构(含同名根合并、含排序)
127
+ * 作唯一真相源 —— 外部按单 rootId 取清单会漏掉同名的另外几个根。
128
+ * @returns {Promise<Array<{model:object, groupKey:string, charKey:string}>>}
520
129
  */
521
130
  async function listVariants() {
522
131
  const out = [];
523
- for (const game of state.games) {
524
- if (state.expandedGames && !state.expandedGames.size) { /* 未展开也要能翻:下面统一加载 */ }
132
+ for (const group of state.groups) {
133
+ if (state.expandedGroups && !state.expandedGroups.size) { /* 未展开也要能翻:下面统一加载 */ }
525
134
  // 目标游戏可能还没加载(用户没展开过):按需加载一次,保证序列完整
526
- if (!state.gameChars.get(game.key)) {
527
- try { await ensureGameLoaded(game); } catch { continue; }
135
+ if (!state.groupChars.get(group.key)) {
136
+ try { await load.ensureGroupLoaded(group); } catch { continue; }
528
137
  }
529
- const entry = state.gameChars.get(game.key);
138
+ const entry = state.groupChars.get(group.key);
530
139
  if (!entry) continue;
531
140
  for (const c of entry.chars) {
532
141
  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 });
142
+ if (v && v.model && v.model.id) out.push({ model: v.model, groupKey: group.key, charKey: c.key });
534
143
  }
535
144
  }
536
145
  }
@@ -539,103 +148,98 @@ export function createRosterView({ listEl, countEl, searchEl, collapseBtn, onSel
539
148
 
540
149
  /**
541
150
  * 取某个变体的**兄弟变体**(同一角色/同一 charKey 下的全部变体)。
542
- * @param {string} modelId
543
- * @returns {Promise<Array<{model:object, gameKey:string, charKey:string}>>}
151
+ * @returns {Promise<Array<{model:object, groupKey:string, charKey:string}>>}
544
152
  */
545
153
  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; }
154
+ for (const group of state.groups) {
155
+ if (!state.groupChars.get(group.key)) {
156
+ try { await load.ensureGroupLoaded(group); } catch { continue; }
549
157
  }
550
- const entry = state.gameChars.get(game.key);
158
+ const entry = state.groupChars.get(group.key);
551
159
  if (!entry) continue;
552
160
  for (const c of entry.chars) {
553
161
  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 }));
162
+ if (hit) return c.variants.filter((v) => v && v.model).map((v) => ({ model: v.model, groupKey: group.key, charKey: c.key }));
555
163
  }
556
164
  }
557
165
  return [];
558
166
  }
559
167
 
560
168
  /**
561
- * 按 modelId 选中某个变体并同步左栏(**外部入口专用**)。
562
- *
563
- * 为什么需要它:左栏点击时选中态由 onVariantClick 自己维护,但"上一个/下一个"、快捷键等
564
- * 外部入口只调装载、不碰左栏 ⇒ 树上高亮停在上一个模型,看起来像没切过去。
565
- * 这里把"选中态 + 展开 + 渲染 + 定位 + 存盘"收成一处,外部入口只调它。
566
- * 不触发 onSelect:装载由调用方的统一入口负责,避免同一次切换装载两遍。
567
- * @param {string} modelId
169
+ * 按 modelId 选中某个变体并同步左栏(**外部入口专用**:上/下一个、快捷键等只调装载、不碰左栏,
170
+ * 树上高亮就会停在上一个模型)。不触发 onSelect:装载由调用方统一入口负责,避免切换装载两遍。
568
171
  * @param {{reveal?: boolean}} [opts] reveal=false 时不滚动定位(默认滚到可视区)
569
- * @returns {Promise<{ok:boolean, gameKey?:string, charKey?:string, reason?:string}>}
172
+ * @returns {Promise<{ok:boolean, groupKey?:string, charKey?:string, reason?:string}>}
570
173
  */
571
174
  async function selectById(modelId, opts = {}) {
572
175
  if (!modelId) return { ok: false, reason: 'no-id' };
573
- for (const game of state.games) {
176
+ for (const group of state.groups) {
574
177
  // 目标可能属于还没展开/加载的游戏:按需加载一次(与启动恢复同策略,不预取全部)
575
- if (!state.gameChars.get(game.key)) {
576
- try { await ensureGameLoaded(game); } catch { continue; }
178
+ if (!state.groupChars.get(group.key)) {
179
+ try { await load.ensureGroupLoaded(group); } catch { continue; }
577
180
  }
578
- const hit = findVariant(game, modelId);
181
+ const hit = findVariant(group, modelId);
579
182
  if (!hit) continue;
580
- state.selected = { modelId, gameKey: game.key, gameName: game.name, charKey: hit.charKey };
581
- state.activeGame = game.name;
183
+ store.selectModel({ modelId, groupKey: group.key, groupName: group.name, charKey: hit.charKey });
184
+ store.setActiveGroup(group.name);
185
+ try { saveLastModel(hit.variant && hit.variant.model ? hit.variant.model : null); } catch { /* 忽略 */ }
582
186
  // 不展开的话目标行根本没渲染出来,高亮也就无从显示
583
- state.expandedGames.add(game.key);
584
- state.expandedChars.add(hit.charKey);
187
+ store.expandGroup(group.key);
188
+ store.expandChar(hit.charKey);
585
189
  render();
586
- if (opts.reveal !== false) state.lastReveal = reveal.revealActive();
190
+ if (opts.reveal !== false) store.setLastReveal(reveal.revealActive());
587
191
  saveState(state);
588
- return { ok: true, gameKey: game.key, charKey: hit.charKey };
192
+ return { ok: true, groupKey: group.key, charKey: hit.charKey };
589
193
  }
590
194
  return { ok: false, reason: 'not-found' };
591
195
  }
592
196
 
593
197
  /**
594
- * 启动恢复:按存档把上次选中的变体重新选中并渲染。
595
- * 懒加载顺序:目标变体所属游戏可能还没加载 ⇒ 先按游戏逐个加载再看命中,不预取全部。
596
- * 失效兜底:所有游戏都找过仍没有 ⇒ 安静回落空舞台(不展开、不报错、不留半选中状态)。
198
+ * 启动恢复:按存档把上次选中的变体重新选中并渲染。找不到 ⇒ 安静回落空舞台(不展开、不报错、不留半选中)
597
199
  * @returns {Promise<{restored: boolean, modelId?: string, reason?: string}>}
598
200
  */
599
201
  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;
202
+ // 冷启动加载策略(唯一允许的启动加载):读「上次所用模型」指针 → **只加载它所在的那一个根**
203
+ // → 展开并定位到它。没有指针 ⇒ 一个根都不扫;有指针但失败 ⇒ 该行显示「加载失败 · 重试」。
204
+ const pointer = readLastModel();
205
+ if (!pointer) return { restored: false, reason: 'no-pointer' };
206
+
207
+ const group = state.groups.find((g) => g.roots.some((r) => r.id === pointer.rootId));
208
+ if (!group) return { restored: false, reason: 'root-gone', rootId: pointer.rootId };
209
+ const root = group.roots.find((r) => r.id === pointer.rootId);
210
+
211
+ try {
212
+ await load.ensureRootLoaded(root); // 只有这一个根(1 次 catalog + 1 条进度流)
213
+ } catch {
214
+ return { restored: false, reason: 'load-failed', rootId: pointer.rootId };
215
+ }
216
+ // 归并判据是"**groupChars 里该组真有 chars**",不是"根有 models":
217
+ // 根就绪 ≠ 角色树已归并(归并是另一件事,任何一条路径漏了它都会让下面 findVariant
218
+ // 误判成 model-gone —— 表现就是"加载完成后 0 角色、收起再展开才出现")。
219
+ // 正常情况下 setRootModels 已经自动归并过,这里只是一次幂等兜底。
220
+ if (!hasGroupChars(group)) renderModule.rebuildGroupChars(group);
221
+ store.expandGroup(group.key);
222
+
223
+ const hit = findVariant(group, pointer.modelId);
224
+ if (!hit) {
617
225
  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 };
226
+ saveState(state);
227
+ return { restored: false, reason: 'model-gone', rootId: pointer.rootId };
623
228
  }
624
- // 存档里的模型已不存在:清掉半状态,保持空舞台
625
- state.selected = null;
626
- render();
627
- return { restored: false, reason: 'missing', modelId };
229
+ store.expandChar(hit.charKey);
230
+ // 关键:复用"用户点击变体"的同一入口 —— 它写入选中态、存盘,并调用 onSelect 真正装载模型。
231
+ // 只设置 state.selected + render() 是不够的:那只是"列表里高亮",画布上什么都不会出现。
232
+ // **等它落地**:调用方(mount)要等首个模型真的出来才放行加载队列,
233
+ // 否则队列的整根扫描会和首个模型的资源请求抢同一份磁盘 IO。
234
+ await events.onVariantClick(pointer.modelId, group.key, hit.charKey);
235
+ store.setLastReveal(reveal.revealActive());
236
+ return { restored: true, modelId: pointer.modelId, rootId: pointer.rootId, groupKey: group.key };
628
237
  }
629
238
 
630
239
  /**
631
240
  * 把服务端的三字段写回名册条目并就地重画那一行(契约 §一 INV-D1 的前端落点)。
632
- *
633
- * 为什么"写回对象"和"刷 DOM"两件事都要做(只刷 DOM 就是之前那个 bug):
634
- * DOM 是一次性快照,`render()` 每次都用条目对象重画该行。
635
- * 实测过的错位:装载成功后只改了 DOM ⇒ 当场看着对了,
636
- * 随后任意一次重渲染(切换模型 / 搜索 / 展开折叠)又把旧值画回来 ⇒ "马上变回中性色"。
637
- * 所以权威写回必须落在**名册持有的那个对象**上(modelById 与 buildChars 的
638
- * variants[].model 是同一引用,改它两边都生效)。
241
+ * 为什么"写回对象"和"刷 DOM"都要做(只刷 DOM 就是之前那个 bug):DOM 是一次性快照,render() 每次都用
242
+ * 条目对象重画 ⇒ 只改 DOM 的话,任一次重渲染又把旧值画回来("马上变回中性色")。
639
243
  * @param {string} modelId 条目 id
640
244
  * @param {{display?:object, support?:object, loadResult?:object}|null} entryState 服务端三字段
641
245
  * @returns {boolean} 条目不在名册里返回 false(调用方可忽略)
@@ -653,15 +257,15 @@ export function createRosterView({ listEl, countEl, searchEl, collapseBtn, onSel
653
257
  }
654
258
 
655
259
  return {
656
- mount, state, snapshot, render, ensureGameLoaded, selectById, listVariants, variantsOf,
260
+ mount, state, snapshot, render, ensureGroupLoaded: load.ensureGroupLoaded, selectById, listVariants, variantsOf,
657
261
  setEntryState,
658
262
  restoreLast,
659
263
  revealActive: reveal.revealActive,
660
264
  markUserScroll: reveal.markUserScroll,
661
265
  /** 调试计数(渲染/加载次数):优化前后用同一口径对比 */
662
266
  counters: () => ({ ...counters }),
663
- /** 名册加载计时快照(`window.__WEB__.rosterStats()` 用它;口径见文件内"名册加载计时"注释) */
664
- rosterStats,
267
+ /** 名册加载计时快照(`window.__WEB__.rosterStats()` 用它;口径见 load.js) */
268
+ rosterStats: load.rosterStats,
665
269
  /** 立刻落地待渲染的请求(测试/自检用:不依赖 rAF 时机) */
666
270
  flushRender,
667
271
  /** 归零计数(测量前调用,避免把上一段的调用混进来) */