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.
- package/CHANGELOG.md +83 -0
- package/README.md +2 -56
- package/bin/modelstage.mjs +48 -12
- package/config/models/live2d.assetbundls.json +1 -1
- package/config/models/live2d.azurlane.json +1 -1
- package/config/models/live2d.azurlane_bundle.json +1 -1
- package/config/models/live2d.azurlane_bundle.jsonl +2 -0
- package/config/models/live2d.azurlane_spine.json +1 -1
- package/config/models/live2d.bd2.json +1 -1
- package/config/models/live2d.bd2.jsonl +2 -0
- package/config/models/live2d.bluearchive.json +1 -1
- package/config/models/live2d.nikke.json +1 -1
- package/config/models/live2d.nikke.jsonl +2 -0
- package/config/models/live2d.nikke_2.json +1 -1
- package/config/models/live2d.npc.json +1 -1
- package/config/models/spine.assetbundls.json +1 -1
- package/config/models/spine.azurlane.json +1 -1
- package/config/models/spine.azurlane_bundle.json +1 -1
- package/config/models/spine.azurlane_bundle.jsonl +2 -0
- package/config/models/spine.azurlane_spine.json +1 -1
- package/config/models/spine.bd2.json +1 -1
- package/config/models/spine.bd2.jsonl +433 -0
- package/config/models/spine.bluearchive.json +1 -1
- package/config/models/spine.nikke.json +1 -1
- package/config/models/spine.nikke_2.json +1 -1
- package/config/models/spine.npc.json +1 -1
- package/package.json +8 -2
- package/src/server/assets/probe.mjs +1 -1
- package/src/server/core/config.mjs +2 -2
- package/src/server/core/log.mjs +1 -1
- package/src/server/data/catalog.mjs +11 -14
- package/src/server/data/live2d.mjs +139 -40
- package/src/server/data/manifest-cache.mjs +53 -10
- package/src/server/data/manifest-jsonl.mjs +264 -0
- package/src/server/data/manifest-stamp.mjs +52 -2
- package/src/server/data/scan-progress.mjs +305 -0
- package/src/server/data/scan-queue.mjs +99 -0
- package/src/server/data/spine.mjs +324 -106
- package/src/server/http/write.mjs +8 -1
- package/src/server/plugins/registry.mjs +1 -1
- package/src/server/routes/data.mjs +76 -17
- package/src/server/serve.mjs +71 -4
- package/src/server/unpack/plugin-store.mjs +1 -1
- package/src/server/unpack/runtime/exec.mjs +37 -8
- package/src/server/unpack/worker-client.mjs +23 -8
- package/src/shared/scan-limits.mjs +23 -0
- package/src/shared/type-labels.mjs +31 -0
- package/src/web/app/anim-list.js +1 -1
- package/src/web/app/backgrounds.js +4 -8
- package/src/web/app/dir-picker.js +3 -5
- package/src/web/app/memory-budget.js +0 -1
- package/src/web/app/memory-guard.js +0 -1
- package/src/web/app/model-actions.js +18 -15
- package/src/web/app/root-manager.js +2 -4
- package/src/web/app/transport-actions.js +1 -1
- package/src/web/app/ui-state.js +1 -1
- package/src/web/components.css +149 -0
- package/src/web/debug/race-probe.js +36 -13
- package/src/web/engine/textures.js +1 -2
- package/src/web/index.html +2 -0
- package/src/web/infra/api.js +10 -19
- package/src/web/infra/document.js +3 -3
- package/src/web/infra/plugin-host-unpack.mjs +81 -9
- package/src/web/infra/ready.js +177 -39
- package/src/web/live2d/bridge.js +1 -1
- package/src/web/live2d/runtime-adapter.js +2 -4
- package/src/web/panel/panel-settings.js +88 -3
- package/src/web/roster/events.js +159 -0
- package/src/web/roster/filter.js +4 -4
- package/src/web/roster/list.js +3 -3
- package/src/web/roster/load.js +440 -0
- package/src/web/roster/persist.js +64 -15
- package/src/web/roster/render.js +284 -0
- package/src/web/roster/reveal.js +4 -9
- package/src/web/roster/rows.js +35 -13
- package/src/web/roster/scan-stream.js +346 -0
- package/src/web/roster/snapshot.js +16 -8
- package/src/web/roster/store.js +292 -0
- package/src/web/roster/type-filter.js +97 -0
- package/src/web/roster/view.js +148 -544
- package/src/web/spine/canvas-runtime.js +4 -5
- package/src/web/spine/runtimes.js +1 -1
- package/tool/lib/check-perms.mjs +0 -114
- package/tool/lib/contract-checks.mjs +0 -582
- package/tool/lib/cubism-core.mjs +0 -122
- package/tool/lib/export-bundle.mjs +0 -328
- package/tool/lib/spine-runtime.mjs +0 -131
- package/tool/lib/test-lock.mjs +0 -178
- package/tool/lib/test-report.mjs +0 -133
package/src/web/roster/view.js
CHANGED
|
@@ -1,506 +1,119 @@
|
|
|
1
|
-
// roster-view.js ——
|
|
1
|
+
// roster-view.js —— 名册视图层(M2):三级树(游戏 → 角色 → 变体)+ 搜索 + 选中态
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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 {
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
} from './
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
/**
|
|
92
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
42
|
+
// 名册持有的模型对象(渲染与选中都读它,跨模块共用同一份引用)
|
|
157
43
|
const namesLoaded = new Set();
|
|
158
44
|
const modelById = new Map();
|
|
159
45
|
|
|
160
|
-
// ------------------------------------------------------------
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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 {
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
476
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
/**
|
|
493
|
-
function
|
|
494
|
-
const
|
|
495
|
-
|
|
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(
|
|
503
|
-
const entry = state.
|
|
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
|
|
524
|
-
if (state.
|
|
132
|
+
for (const group of state.groups) {
|
|
133
|
+
if (state.expandedGroups && !state.expandedGroups.size) { /* 未展开也要能翻:下面统一加载 */ }
|
|
525
134
|
// 目标游戏可能还没加载(用户没展开过):按需加载一次,保证序列完整
|
|
526
|
-
if (!state.
|
|
527
|
-
try { await
|
|
135
|
+
if (!state.groupChars.get(group.key)) {
|
|
136
|
+
try { await load.ensureGroupLoaded(group); } catch { continue; }
|
|
528
137
|
}
|
|
529
|
-
const entry = state.
|
|
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,
|
|
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
|
-
* @
|
|
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
|
|
547
|
-
if (!state.
|
|
548
|
-
try { await
|
|
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.
|
|
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,
|
|
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,
|
|
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
|
|
176
|
+
for (const group of state.groups) {
|
|
574
177
|
// 目标可能属于还没展开/加载的游戏:按需加载一次(与启动恢复同策略,不预取全部)
|
|
575
|
-
if (!state.
|
|
576
|
-
try { await
|
|
178
|
+
if (!state.groupChars.get(group.key)) {
|
|
179
|
+
try { await load.ensureGroupLoaded(group); } catch { continue; }
|
|
577
180
|
}
|
|
578
|
-
const hit = findVariant(
|
|
181
|
+
const hit = findVariant(group, modelId);
|
|
579
182
|
if (!hit) continue;
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
584
|
-
|
|
187
|
+
store.expandGroup(group.key);
|
|
188
|
+
store.expandChar(hit.charKey);
|
|
585
189
|
render();
|
|
586
|
-
if (opts.reveal !== false)
|
|
190
|
+
if (opts.reveal !== false) store.setLastReveal(reveal.revealActive());
|
|
587
191
|
saveState(state);
|
|
588
|
-
return { ok: true,
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
await
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
619
|
-
|
|
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
|
-
|
|
626
|
-
render()
|
|
627
|
-
|
|
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
|
-
*
|
|
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,
|
|
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
|
/** 归零计数(测量前调用,避免把上一段的调用混进来) */
|