消消方块阵换皮表情
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

266 lines
7.8 KiB

import type {
StartupEnterGameListener,
StartupGraph,
StartupGraphNode,
StartupOverallListener,
StartupStateListener,
StartupUIState,
StartupWorkContext,
} from './StartupWorkerTypes';
function clamp01(v: number): number {
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
function sumWeights(nodes: StartupGraphNode[]): number {
return nodes.reduce((s, n) => s + (n.weight ?? 1), 0);
}
function initialState(): StartupUIState {
return {
overall: 0,
activeId: null,
activeLabel: null,
activeProgress: 0,
scopeId: null,
scopeLabel: null,
lastEvent: null,
phase: 'idle',
error: null,
};
}
function checkAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
const e = new Error('Startup aborted');
e.name = 'StartupAbortError';
throw e;
}
}
function validateGraph(graph: StartupGraph): void {
const nodes = graph.nodes;
const ids = new Set(nodes.map((n) => n.id));
if (ids.size !== nodes.length) {
throw new Error('StartupGraph: 存在重复的节点 id');
}
for (const n of nodes) {
if (!n.id) {
throw new Error('StartupGraph: 节点缺少 id');
}
for (const d of n.dependsOn ?? []) {
if (!ids.has(d)) {
throw new Error(`StartupGraph: 节点 "${n.id}" 依赖不存在的节点 "${d}"`);
}
}
}
const nodeById = new Map(nodes.map((n) => [n.id, n]));
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
const color = new Map<string, number>();
for (const n of nodes) {
color.set(n.id, WHITE);
}
const dfs = (id: string): void => {
const c = color.get(id) ?? WHITE;
if (c === BLACK) {
return;
}
if (c === GRAY) {
throw new Error(`StartupGraph: 存在环,涉及节点 "${id}"`);
}
color.set(id, GRAY);
const n = nodeById.get(id);
for (const d of n?.dependsOn ?? []) {
dfs(d);
}
color.set(id, BLACK);
};
for (const n of nodes) {
dfs(n.id);
}
}
/**
* 按有向无环图执行:依赖全部完成后节点才开始;就绪节点之间并行。
* 总进度 = Σ(weight × 节点进度) / Σ(weight)。
*/
export class StartupWorker {
private state: StartupUIState = initialState();
private readonly stateListeners = new Set<StartupStateListener>();
private readonly overallListeners = new Set<StartupOverallListener>();
private readonly enterGameListeners = new Set<StartupEnterGameListener>();
constructor(private readonly graph: StartupGraph) {}
getState(): Readonly<StartupUIState> {
return this.state;
}
subscribeState(fn: StartupStateListener): () => void {
this.stateListeners.add(fn);
return () => this.stateListeners.delete(fn);
}
/** 仅总进度变化时回调,适合驱动加载条 */
subscribeOverall(fn: StartupOverallListener): () => void {
this.overallListeners.add(fn);
return () => this.overallListeners.delete(fn);
}
onEnterGame(fn: StartupEnterGameListener): () => void {
this.enterGameListeners.add(fn);
return () => this.enterGameListeners.delete(fn);
}
private patch(partial: Partial<StartupUIState>): void {
const prevOverall = this.state.overall;
this.state = { ...this.state, ...partial };
if ('overall' in partial && this.state.overall !== prevOverall) {
for (const fn of this.overallListeners) {
fn(this.state.overall);
}
}
for (const fn of this.stateListeners) {
fn(this.state);
}
}
async run(options?: { signal?: AbortSignal }): Promise<void> {
const signal = options?.signal;
validateGraph(this.graph);
this.state = initialState();
this.patch({
phase: 'running',
overall: 0,
error: null,
activeId: null,
activeLabel: null,
activeProgress: 0,
scopeId: null,
scopeLabel: null,
lastEvent: null,
});
const nodes = this.graph.nodes;
const W = sumWeights(nodes);
const nodeById = new Map(nodes.map((n) => [n.id, n]));
const progress = new Map<string, number>();
for (const n of nodes) {
progress.set(n.id, 0);
}
const updateOverall = (): void => {
if (W <= 0) {
return;
}
let num = 0;
for (const n of nodes) {
num += (n.weight ?? 1) * (progress.get(n.id) ?? 0);
}
const o = clamp01(num / W);
if (o !== this.state.overall) {
this.patch({ overall: o });
}
};
const completion = new Map<string, Promise<void>>();
const runNode = (id: string): Promise<void> => {
const cached = completion.get(id);
if (cached) {
return cached;
}
const node = nodeById.get(id);
if (!node) {
return Promise.resolve();
}
const p = (async () => {
checkAborted(signal);
const deps = node.dependsOn ?? [];
await Promise.all(deps.map((depId) => runNode(depId)));
checkAborted(signal);
let last = 0;
const ctx: StartupWorkContext = {
id: node.id,
label: node.label,
report: (pv: number) => {
last = clamp01(pv);
progress.set(node.id, last);
updateOverall();
this.patch({
activeId: node.id,
activeLabel: node.label ?? null,
activeProgress: last,
});
},
emit: (name: string, payload?: unknown) => {
this.patch({ lastEvent: { name, payload } });
},
signal,
};
this.patch({ lastEvent: { name: node.id, payload: 'start' } });
try {
await node.work(ctx);
} catch (err) {
const e = err instanceof Error ? err : new Error(String(err));
e.message = `[startup:${node.id}] ${e.message}`;
throw e;
}
if (last < 1) {
progress.set(node.id, 1);
updateOverall();
this.patch({
activeId: node.id,
activeLabel: node.label ?? null,
activeProgress: 1,
});
}
})();
completion.set(id, p);
return p;
};
try {
checkAborted(signal);
await Promise.all(nodes.map((n) => runNode(n.id)));
this.patch({
overall: 1,
phase: 'success',
activeId: null,
activeLabel: null,
activeProgress: 0,
scopeId: null,
scopeLabel: null,
});
const cbs = [...this.enterGameListeners];
for (const fn of cbs) {
fn();
}
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
this.patch({
phase: 'failed',
error: err,
activeId: null,
activeProgress: 0,
});
throw e;
}
}
}