647cf82cec
新增直连 Codex 项目聊天、会话恢复与可读错误反馈 接入陶泥儿规范图、背景、固定四切片并校验实际渲染 同步游戏代码资源分类、项目版本 revision 与外部编辑器契约
978 lines
30 KiB
TypeScript
978 lines
30 KiB
TypeScript
import {
|
|
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
|
GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
|
|
type ProjectResourceCanvasLayout,
|
|
type ProjectResourceCanvasLayoutMode,
|
|
type ProjectResourceCanvasPosition,
|
|
type ProjectResourceCanvasSection,
|
|
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
|
|
|
export const RESOURCE_CANVAS_CARD_WIDTH = 180;
|
|
export const RESOURCE_CANVAS_CARD_HEIGHT = 128;
|
|
export const RESOURCE_CANVAS_COLUMN_GAP = 16;
|
|
export const RESOURCE_CANVAS_ROW_GAP = 16;
|
|
export const RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP = 48;
|
|
export const RESOURCE_CANVAS_DEPENDENCY_ROW_GAP = 40;
|
|
export const RESOURCE_CANVAS_DRAG_THRESHOLD = 5;
|
|
export const RESOURCE_CANVAS_TYPE_COLUMNS = 3;
|
|
export const RESOURCE_CANVAS_SECTION_MIN_WIDTH = 620;
|
|
export const RESOURCE_CANVAS_SECTION_MIN_HEIGHT = 108;
|
|
export const RESOURCE_CANVAS_CLUSTER_GAP = 48;
|
|
|
|
const sectionOrder: ProjectResourceCanvasSection[] = [
|
|
'code',
|
|
'document',
|
|
'version',
|
|
'art',
|
|
'audio',
|
|
];
|
|
|
|
export type ResourceCanvasItem = {
|
|
id: string;
|
|
category: ProjectResourceCanvasSection;
|
|
subtype: string;
|
|
label: string;
|
|
mediaType: string;
|
|
dependencyDepth: number;
|
|
};
|
|
|
|
/**
|
|
* This is deliberately a display-only projection of the Rust resource graph.
|
|
* It never becomes a second dependency source: `dependencyDepth` remains on
|
|
* ResourceCanvasItem and is still the sole authority for the horizontal layer.
|
|
*/
|
|
export type ResourceCanvasLayoutTopology = {
|
|
referenceEdges: readonly {
|
|
sourceResourceId: string;
|
|
targetResourceId: string;
|
|
}[];
|
|
taskFlows: readonly {
|
|
sourceResourceIds: readonly string[];
|
|
targetResourceIds: readonly string[];
|
|
}[];
|
|
};
|
|
|
|
export type ReconciledResourceCanvasLayout = {
|
|
layout: ProjectResourceCanvasLayout;
|
|
changed: boolean;
|
|
};
|
|
|
|
export function createEmptyResourceCanvasLayout(
|
|
projectId: string,
|
|
mode: ProjectResourceCanvasLayoutMode,
|
|
): ProjectResourceCanvasLayout {
|
|
return {
|
|
schemaVersion: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
|
|
projectId,
|
|
mode,
|
|
revision: 0,
|
|
positions: [],
|
|
updatedAt: 0,
|
|
};
|
|
}
|
|
|
|
function positionsEqual(
|
|
left: ProjectResourceCanvasPosition[],
|
|
right: ProjectResourceCanvasPosition[],
|
|
) {
|
|
return (
|
|
left.length === right.length &&
|
|
left.every((position, index) => {
|
|
const other = right[index];
|
|
return (
|
|
other !== undefined &&
|
|
position.resourceId === other.resourceId &&
|
|
position.section === other.section &&
|
|
position.x === other.x &&
|
|
position.y === other.y &&
|
|
position.manuallyPlaced === other.manuallyPlaced
|
|
);
|
|
})
|
|
);
|
|
}
|
|
|
|
const RESOURCE_CANVAS_SLOT_WIDTH =
|
|
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP;
|
|
const RESOURCE_CANVAS_SLOT_HEIGHT =
|
|
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP;
|
|
const RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH =
|
|
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP;
|
|
const RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT =
|
|
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP;
|
|
const RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN = Math.floor(
|
|
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE /
|
|
RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH,
|
|
);
|
|
|
|
function positionsOverlap(
|
|
leftX: number,
|
|
leftY: number,
|
|
right: ProjectResourceCanvasPosition,
|
|
) {
|
|
return (
|
|
leftX < right.x + RESOURCE_CANVAS_SLOT_WIDTH &&
|
|
leftX + RESOURCE_CANVAS_SLOT_WIDTH > right.x &&
|
|
leftY < right.y + RESOURCE_CANVAS_SLOT_HEIGHT &&
|
|
leftY + RESOURCE_CANVAS_SLOT_HEIGHT > right.y
|
|
);
|
|
}
|
|
|
|
class ResourceCanvasOccupancyIndex {
|
|
private readonly positionsByCell = new Map<
|
|
string,
|
|
ProjectResourceCanvasPosition[]
|
|
>();
|
|
|
|
constructor(positions: ProjectResourceCanvasPosition[]) {
|
|
positions.forEach((position) => this.add(position));
|
|
}
|
|
|
|
private cellKeys(x: number, y: number) {
|
|
const firstColumn = Math.floor(x / RESOURCE_CANVAS_SLOT_WIDTH);
|
|
const lastColumn = Math.floor(
|
|
(x + RESOURCE_CANVAS_SLOT_WIDTH - 1) / RESOURCE_CANVAS_SLOT_WIDTH,
|
|
);
|
|
const firstRow = Math.floor(y / RESOURCE_CANVAS_SLOT_HEIGHT);
|
|
const lastRow = Math.floor(
|
|
(y + RESOURCE_CANVAS_SLOT_HEIGHT - 1) / RESOURCE_CANVAS_SLOT_HEIGHT,
|
|
);
|
|
const keys: string[] = [];
|
|
for (let column = firstColumn; column <= lastColumn; column += 1) {
|
|
for (let row = firstRow; row <= lastRow; row += 1) {
|
|
keys.push(`${column}:${row}`);
|
|
}
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
add(position: ProjectResourceCanvasPosition) {
|
|
for (const key of this.cellKeys(position.x, position.y)) {
|
|
const positions = this.positionsByCell.get(key);
|
|
if (positions) {
|
|
positions.push(position);
|
|
} else {
|
|
this.positionsByCell.set(key, [position]);
|
|
}
|
|
}
|
|
}
|
|
|
|
overlaps(x: number, y: number) {
|
|
const visited = new Set<ProjectResourceCanvasPosition>();
|
|
for (const key of this.cellKeys(x, y)) {
|
|
for (const position of this.positionsByCell.get(key) ?? []) {
|
|
if (!visited.has(position)) {
|
|
visited.add(position);
|
|
if (positionsOverlap(x, y, position)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function defaultTypePosition(
|
|
occupancy: ResourceCanvasOccupancyIndex,
|
|
nextSlot: number,
|
|
) {
|
|
let slot = nextSlot;
|
|
for (;;) {
|
|
const column = slot % RESOURCE_CANVAS_TYPE_COLUMNS;
|
|
const row = Math.floor(slot / RESOURCE_CANVAS_TYPE_COLUMNS);
|
|
const x = column * RESOURCE_CANVAS_SLOT_WIDTH;
|
|
const y = row * RESOURCE_CANVAS_SLOT_HEIGHT;
|
|
if (!occupancy.overlaps(x, y)) {
|
|
return { point: { x, y }, nextSlot: slot + 1 };
|
|
}
|
|
slot += 1;
|
|
}
|
|
}
|
|
|
|
function compareStableIdentifier(left: string, right: string) {
|
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
}
|
|
|
|
function compareResources(
|
|
mode: ProjectResourceCanvasLayoutMode,
|
|
left: ResourceCanvasItem,
|
|
right: ResourceCanvasItem,
|
|
) {
|
|
if (mode === 'dependency') {
|
|
return (
|
|
left.dependencyDepth - right.dependencyDepth ||
|
|
left.label.localeCompare(right.label, 'zh-CN') ||
|
|
compareStableIdentifier(left.id, right.id)
|
|
);
|
|
}
|
|
return (
|
|
compareStableIdentifier(left.subtype, right.subtype) ||
|
|
compareStableIdentifier(left.mediaType, right.mediaType) ||
|
|
left.label.localeCompare(right.label, 'zh-CN') ||
|
|
compareStableIdentifier(left.id, right.id)
|
|
);
|
|
}
|
|
|
|
type DependencyFlow = {
|
|
sourceResourceIds: string[];
|
|
targetResourceIds: string[];
|
|
};
|
|
|
|
type DependencyComponent = {
|
|
resourceIds: string[];
|
|
minimumDepth: number;
|
|
minimumResourceId: string;
|
|
related: boolean;
|
|
};
|
|
|
|
type DependencyCycle = {
|
|
resourceIds: string[];
|
|
minimumResourceId: string;
|
|
};
|
|
|
|
type DependencyCluster = {
|
|
resourceIds: string[];
|
|
minimumDepth: number;
|
|
minimumResourceId: string;
|
|
related: boolean;
|
|
baseY: number;
|
|
rowCount: number;
|
|
};
|
|
|
|
const DEPENDENCY_ORDERING_SCAN_ROUNDS = 2;
|
|
|
|
function uniqueSortedResourceIds(values: Iterable<string>) {
|
|
return Array.from(new Set(values)).sort(compareStableIdentifier);
|
|
}
|
|
|
|
function median(values: readonly number[]) {
|
|
if (values.length === 0) {
|
|
return null;
|
|
}
|
|
const sorted = [...values].sort((left, right) => left - right);
|
|
const middle = Math.floor(sorted.length / 2);
|
|
return sorted.length % 2 === 1
|
|
? sorted[middle]!
|
|
: (sorted[middle - 1]! + sorted[middle]!) / 2;
|
|
}
|
|
|
|
function addUndirectedNeighbor(
|
|
neighborsByNodeId: Map<string, Set<string>>,
|
|
left: string,
|
|
right: string,
|
|
) {
|
|
let leftNeighbors = neighborsByNodeId.get(left);
|
|
if (!leftNeighbors) {
|
|
leftNeighbors = new Set();
|
|
neighborsByNodeId.set(left, leftNeighbors);
|
|
}
|
|
leftNeighbors.add(right);
|
|
let rightNeighbors = neighborsByNodeId.get(right);
|
|
if (!rightNeighbors) {
|
|
rightNeighbors = new Set();
|
|
neighborsByNodeId.set(right, rightNeighbors);
|
|
}
|
|
rightNeighbors.add(left);
|
|
}
|
|
|
|
/**
|
|
* Iterative Kosaraju traversal. The Rust depth is still authoritative; SCCs
|
|
* here are only a visual contiguity hint for same-depth automatic cards.
|
|
*/
|
|
function dependencyCycles(
|
|
resourceIds: readonly string[],
|
|
downstreamIdsByResourceId: ReadonlyMap<string, ReadonlySet<string>>,
|
|
upstreamIdsByResourceId: ReadonlyMap<string, ReadonlySet<string>>,
|
|
) {
|
|
const orderedResourceIds = uniqueSortedResourceIds(resourceIds);
|
|
const visited = new Set<string>();
|
|
const finishOrder: string[] = [];
|
|
for (const startResourceId of orderedResourceIds) {
|
|
if (visited.has(startResourceId)) {
|
|
continue;
|
|
}
|
|
const pending: Array<{ resourceId: string; expanded: boolean }> = [
|
|
{ resourceId: startResourceId, expanded: false },
|
|
];
|
|
while (pending.length > 0) {
|
|
const current = pending.pop()!;
|
|
if (current.expanded) {
|
|
finishOrder.push(current.resourceId);
|
|
continue;
|
|
}
|
|
if (visited.has(current.resourceId)) {
|
|
continue;
|
|
}
|
|
visited.add(current.resourceId);
|
|
pending.push({ resourceId: current.resourceId, expanded: true });
|
|
const neighbors = downstreamIdsByResourceId.get(current.resourceId) ?? [];
|
|
for (const neighbor of neighbors) {
|
|
if (!visited.has(neighbor)) {
|
|
pending.push({ resourceId: neighbor, expanded: false });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const componentByResourceId = new Map<string, true>();
|
|
const cycles: DependencyCycle[] = [];
|
|
for (let index = finishOrder.length - 1; index >= 0; index -= 1) {
|
|
const startResourceId = finishOrder[index]!;
|
|
if (componentByResourceId.has(startResourceId)) {
|
|
continue;
|
|
}
|
|
const componentResourceIds: string[] = [];
|
|
const pending = [startResourceId];
|
|
while (pending.length > 0) {
|
|
const resourceId = pending.pop()!;
|
|
if (componentByResourceId.has(resourceId)) {
|
|
continue;
|
|
}
|
|
componentByResourceId.set(resourceId, true);
|
|
componentResourceIds.push(resourceId);
|
|
for (const neighbor of upstreamIdsByResourceId.get(resourceId) ?? []) {
|
|
if (!componentByResourceId.has(neighbor)) {
|
|
pending.push(neighbor);
|
|
}
|
|
}
|
|
}
|
|
const sortedResourceIds = uniqueSortedResourceIds(componentResourceIds);
|
|
const selfReferential =
|
|
sortedResourceIds.length === 1 &&
|
|
Boolean(
|
|
downstreamIdsByResourceId
|
|
.get(sortedResourceIds[0]!)
|
|
?.has(sortedResourceIds[0]!),
|
|
);
|
|
if (sortedResourceIds.length > 1 || selfReferential) {
|
|
cycles.push({
|
|
resourceIds: sortedResourceIds,
|
|
minimumResourceId: sortedResourceIds[0] ?? '',
|
|
});
|
|
}
|
|
}
|
|
return cycles;
|
|
}
|
|
|
|
function dependencyClusters(
|
|
sectionResources: readonly ResourceCanvasItem[],
|
|
topology: ResourceCanvasLayoutTopology | undefined,
|
|
) {
|
|
const resourceById = new Map(
|
|
sectionResources.map((resource) => [resource.id, resource]),
|
|
);
|
|
const neighborsByNodeId = new Map<string, Set<string>>(
|
|
sectionResources.map((resource) => [resource.id, new Set<string>()]),
|
|
);
|
|
const upstreamReferenceIdsByResourceId = new Map<string, Set<string>>(
|
|
sectionResources.map((resource) => [resource.id, new Set<string>()]),
|
|
);
|
|
const downstreamReferenceIdsByResourceId = new Map<string, Set<string>>(
|
|
sectionResources.map((resource) => [resource.id, new Set<string>()]),
|
|
);
|
|
const incomingFlowsByResourceId = new Map<string, DependencyFlow[]>(
|
|
sectionResources.map((resource) => [resource.id, []]),
|
|
);
|
|
const outgoingFlowsByResourceId = new Map<string, DependencyFlow[]>(
|
|
sectionResources.map((resource) => [resource.id, []]),
|
|
);
|
|
const relatedResourceIds = new Set<string>();
|
|
const flows: DependencyFlow[] = [];
|
|
|
|
for (const edge of topology?.referenceEdges ?? []) {
|
|
const source = resourceById.get(edge.sourceResourceId);
|
|
const target = resourceById.get(edge.targetResourceId);
|
|
if (source && target) {
|
|
addUndirectedNeighbor(neighborsByNodeId, source.id, target.id);
|
|
upstreamReferenceIdsByResourceId.get(target.id)?.add(source.id);
|
|
downstreamReferenceIdsByResourceId.get(source.id)?.add(target.id);
|
|
relatedResourceIds.add(source.id);
|
|
relatedResourceIds.add(target.id);
|
|
}
|
|
}
|
|
|
|
const cycles = dependencyCycles(
|
|
sectionResources.map((resource) => resource.id),
|
|
downstreamReferenceIdsByResourceId,
|
|
upstreamReferenceIdsByResourceId,
|
|
);
|
|
|
|
for (const flow of topology?.taskFlows ?? []) {
|
|
const sourceResourceIds = uniqueSortedResourceIds(
|
|
flow.sourceResourceIds.filter((resourceId) =>
|
|
resourceById.has(resourceId),
|
|
),
|
|
);
|
|
const targetResourceIds = uniqueSortedResourceIds(
|
|
flow.targetResourceIds.filter((resourceId) =>
|
|
resourceById.has(resourceId),
|
|
),
|
|
);
|
|
if (sourceResourceIds.length === 0 || targetResourceIds.length === 0) {
|
|
continue;
|
|
}
|
|
const normalizedFlow: DependencyFlow = {
|
|
sourceResourceIds,
|
|
targetResourceIds,
|
|
};
|
|
flows.push(normalizedFlow);
|
|
const members = uniqueSortedResourceIds([
|
|
...sourceResourceIds,
|
|
...targetResourceIds,
|
|
]);
|
|
// Connect the hyperedge as a star, not a cartesian product of members.
|
|
const flowNodeId = `\u0000flow:${flows.length - 1}`;
|
|
neighborsByNodeId.set(flowNodeId, new Set<string>());
|
|
for (const resourceId of members) {
|
|
addUndirectedNeighbor(neighborsByNodeId, flowNodeId, resourceId);
|
|
relatedResourceIds.add(resourceId);
|
|
}
|
|
for (const resourceId of sourceResourceIds) {
|
|
outgoingFlowsByResourceId.get(resourceId)?.push(normalizedFlow);
|
|
}
|
|
for (const resourceId of targetResourceIds) {
|
|
incomingFlowsByResourceId.get(resourceId)?.push(normalizedFlow);
|
|
}
|
|
}
|
|
|
|
const layoutComponentByResourceId = new Map<string, number>();
|
|
const components: DependencyComponent[] = [];
|
|
for (const resource of [...sectionResources].sort((left, right) =>
|
|
compareStableIdentifier(left.id, right.id),
|
|
)) {
|
|
if (layoutComponentByResourceId.has(resource.id)) {
|
|
continue;
|
|
}
|
|
const pending = [resource.id];
|
|
const visitedNodes = new Set<string>();
|
|
const resourceIds: string[] = [];
|
|
while (pending.length > 0) {
|
|
const nodeId = pending.pop()!;
|
|
if (visitedNodes.has(nodeId)) {
|
|
continue;
|
|
}
|
|
visitedNodes.add(nodeId);
|
|
const nodeResource = resourceById.get(nodeId);
|
|
if (nodeResource) {
|
|
resourceIds.push(nodeId);
|
|
}
|
|
for (const neighbor of neighborsByNodeId.get(nodeId) ?? []) {
|
|
if (!visitedNodes.has(neighbor)) {
|
|
pending.push(neighbor);
|
|
}
|
|
}
|
|
}
|
|
const sortedResourceIds = uniqueSortedResourceIds(resourceIds);
|
|
const componentIndex = components.length;
|
|
sortedResourceIds.forEach((resourceId) =>
|
|
layoutComponentByResourceId.set(resourceId, componentIndex),
|
|
);
|
|
components.push({
|
|
resourceIds: sortedResourceIds,
|
|
minimumDepth: Math.min(
|
|
...sortedResourceIds.map(
|
|
(resourceId) => resourceById.get(resourceId)?.dependencyDepth ?? 0,
|
|
),
|
|
),
|
|
minimumResourceId: sortedResourceIds[0] ?? '',
|
|
related: sortedResourceIds.some((resourceId) =>
|
|
relatedResourceIds.has(resourceId),
|
|
),
|
|
});
|
|
}
|
|
|
|
return {
|
|
componentByResourceId: layoutComponentByResourceId,
|
|
cycles,
|
|
components,
|
|
incomingFlowsByResourceId,
|
|
outgoingFlowsByResourceId,
|
|
upstreamReferenceIdsByResourceId,
|
|
downstreamReferenceIdsByResourceId,
|
|
flows,
|
|
};
|
|
}
|
|
|
|
function dependencyTopologyForSection(
|
|
sectionResources: readonly ResourceCanvasItem[],
|
|
topology: ResourceCanvasLayoutTopology | undefined,
|
|
) {
|
|
return dependencyClusters(sectionResources, topology);
|
|
}
|
|
|
|
function sortDependencyBucket(
|
|
resourceIds: string[],
|
|
resourceById: ReadonlyMap<string, ResourceCanvasItem>,
|
|
rankByResourceId: ReadonlyMap<string, number>,
|
|
scanDirection: 'forward' | 'reverse',
|
|
topology: ReturnType<typeof dependencyTopologyForSection>,
|
|
flowSignalByFlow: ReadonlyMap<DependencyFlow, number | null>,
|
|
) {
|
|
type OrderingUnit = {
|
|
resourceIds: string[];
|
|
minimumResourceId: string;
|
|
previousRank: number;
|
|
signal: number | null;
|
|
};
|
|
const resourceIdSet = new Set(resourceIds);
|
|
const claimedResourceIds = new Set<string>();
|
|
const units: OrderingUnit[] = [];
|
|
for (const cycle of topology.cycles) {
|
|
const cycleResourceIds = cycle.resourceIds.filter((resourceId) =>
|
|
resourceIdSet.has(resourceId),
|
|
);
|
|
if (cycleResourceIds.length !== cycle.resourceIds.length) {
|
|
continue;
|
|
}
|
|
cycleResourceIds.forEach((resourceId) =>
|
|
claimedResourceIds.add(resourceId),
|
|
);
|
|
units.push({
|
|
resourceIds: cycleResourceIds,
|
|
minimumResourceId: cycle.minimumResourceId,
|
|
previousRank: Math.min(
|
|
...cycleResourceIds.map(
|
|
(resourceId) => rankByResourceId.get(resourceId) ?? 0,
|
|
),
|
|
),
|
|
signal: null,
|
|
});
|
|
}
|
|
for (const resourceId of resourceIds) {
|
|
if (claimedResourceIds.has(resourceId)) {
|
|
continue;
|
|
}
|
|
units.push({
|
|
resourceIds: [resourceId],
|
|
minimumResourceId: resourceId,
|
|
previousRank: rankByResourceId.get(resourceId) ?? 0,
|
|
signal: null,
|
|
});
|
|
}
|
|
for (const unit of units) {
|
|
const signals: number[] = [];
|
|
for (const resourceId of unit.resourceIds) {
|
|
const resource = resourceById.get(resourceId);
|
|
if (!resource) {
|
|
continue;
|
|
}
|
|
const directNeighborIds =
|
|
scanDirection === 'forward'
|
|
? topology.upstreamReferenceIdsByResourceId.get(resourceId)
|
|
: topology.downstreamReferenceIdsByResourceId.get(resourceId);
|
|
for (const neighborId of directNeighborIds ?? []) {
|
|
const neighbor = resourceById.get(neighborId);
|
|
const rank = rankByResourceId.get(neighborId);
|
|
const isLayerNeighbor =
|
|
neighbor &&
|
|
rank !== undefined &&
|
|
(scanDirection === 'forward'
|
|
? neighbor.dependencyDepth < resource.dependencyDepth
|
|
: neighbor.dependencyDepth > resource.dependencyDepth);
|
|
if (isLayerNeighbor && rank !== undefined) {
|
|
signals.push(rank);
|
|
}
|
|
}
|
|
const flows =
|
|
scanDirection === 'forward'
|
|
? topology.incomingFlowsByResourceId.get(resourceId)
|
|
: topology.outgoingFlowsByResourceId.get(resourceId);
|
|
for (const flow of flows ?? []) {
|
|
const value = flowSignalByFlow.get(flow);
|
|
if (value !== null && value !== undefined) {
|
|
signals.push(value);
|
|
}
|
|
}
|
|
}
|
|
unit.signal = median(signals);
|
|
}
|
|
units.sort((left, right) => {
|
|
const leftSignal = left.signal;
|
|
const rightSignal = right.signal;
|
|
if (leftSignal !== null && leftSignal !== undefined) {
|
|
if (rightSignal === null || rightSignal === undefined) {
|
|
return -1;
|
|
}
|
|
if (leftSignal !== rightSignal) {
|
|
return leftSignal - rightSignal;
|
|
}
|
|
} else if (rightSignal !== null && rightSignal !== undefined) {
|
|
return 1;
|
|
}
|
|
return (
|
|
left.previousRank - right.previousRank ||
|
|
compareStableIdentifier(left.minimumResourceId, right.minimumResourceId)
|
|
);
|
|
});
|
|
resourceIds.splice(
|
|
0,
|
|
resourceIds.length,
|
|
...units.flatMap((unit) =>
|
|
[...unit.resourceIds].sort(
|
|
(left, right) =>
|
|
(rankByResourceId.get(left) ?? 0) -
|
|
(rankByResourceId.get(right) ?? 0) ||
|
|
compareStableIdentifier(left, right),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
function dependencyAutomaticPositions(
|
|
section: ProjectResourceCanvasSection,
|
|
sectionResources: readonly ResourceCanvasItem[],
|
|
automaticResources: readonly ResourceCanvasItem[],
|
|
preservedPositions: readonly ProjectResourceCanvasPosition[],
|
|
topology: ResourceCanvasLayoutTopology | undefined,
|
|
) {
|
|
const resourceById = new Map(
|
|
sectionResources.map((resource) => [resource.id, resource]),
|
|
);
|
|
const automaticIds = new Set(
|
|
automaticResources.map((resource) => resource.id),
|
|
);
|
|
const topologyForSection = dependencyTopologyForSection(
|
|
sectionResources,
|
|
topology,
|
|
);
|
|
const relatedComponents = topologyForSection.components
|
|
.filter((component) => component.related)
|
|
.sort(
|
|
(left, right) =>
|
|
left.minimumDepth - right.minimumDepth ||
|
|
compareStableIdentifier(
|
|
left.minimumResourceId,
|
|
right.minimumResourceId,
|
|
),
|
|
);
|
|
const isolatedResourceIds = topologyForSection.components
|
|
.filter((component) => !component.related)
|
|
.flatMap((component) => component.resourceIds)
|
|
.filter((resourceId) => automaticIds.has(resourceId))
|
|
.sort(compareStableIdentifier);
|
|
|
|
const clusters: DependencyCluster[] = [];
|
|
let nextClusterY = 0;
|
|
for (const component of relatedComponents) {
|
|
const resourceIds = component.resourceIds.filter((resourceId) =>
|
|
automaticIds.has(resourceId),
|
|
);
|
|
if (resourceIds.length === 0) {
|
|
continue;
|
|
}
|
|
const bucketCounts = new Map<number, number>();
|
|
for (const resourceId of resourceIds) {
|
|
const depth = resourceById.get(resourceId)?.dependencyDepth ?? 0;
|
|
bucketCounts.set(depth, (bucketCounts.get(depth) ?? 0) + 1);
|
|
}
|
|
const cluster = {
|
|
resourceIds,
|
|
minimumDepth: component.minimumDepth,
|
|
minimumResourceId: component.minimumResourceId,
|
|
related: true,
|
|
baseY: nextClusterY,
|
|
rowCount: Math.max(1, ...bucketCounts.values()),
|
|
};
|
|
clusters.push(cluster);
|
|
nextClusterY +=
|
|
cluster.rowCount * RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT +
|
|
RESOURCE_CANVAS_CLUSTER_GAP;
|
|
}
|
|
if (isolatedResourceIds.length > 0) {
|
|
const isolatedBucketCounts = new Map<number, number>();
|
|
for (const resourceId of isolatedResourceIds) {
|
|
const depth = resourceById.get(resourceId)?.dependencyDepth ?? 0;
|
|
isolatedBucketCounts.set(
|
|
depth,
|
|
(isolatedBucketCounts.get(depth) ?? 0) + 1,
|
|
);
|
|
}
|
|
clusters.push({
|
|
resourceIds: isolatedResourceIds,
|
|
minimumDepth: Number.MAX_SAFE_INTEGER,
|
|
minimumResourceId: isolatedResourceIds[0] ?? '',
|
|
related: false,
|
|
baseY: nextClusterY,
|
|
rowCount: Math.max(1, ...isolatedBucketCounts.values()),
|
|
});
|
|
}
|
|
|
|
const rankByResourceId = new Map<string, number>();
|
|
for (const position of preservedPositions) {
|
|
rankByResourceId.set(
|
|
position.resourceId,
|
|
position.y / RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT,
|
|
);
|
|
}
|
|
const orderedIdsByClusterAndDepth = new Map<
|
|
DependencyCluster,
|
|
Map<number, string[]>
|
|
>();
|
|
for (const cluster of clusters) {
|
|
const idsByDepth = new Map<number, string[]>();
|
|
for (const resourceId of cluster.resourceIds) {
|
|
const resource = resourceById.get(resourceId);
|
|
if (!resource) {
|
|
continue;
|
|
}
|
|
const ids = idsByDepth.get(resource.dependencyDepth) ?? [];
|
|
ids.push(resourceId);
|
|
idsByDepth.set(resource.dependencyDepth, ids);
|
|
}
|
|
for (const [depth, resourceIds] of idsByDepth) {
|
|
resourceIds.sort(compareStableIdentifier);
|
|
const bucketBaseY =
|
|
cluster.baseY +
|
|
(cluster.related
|
|
? ((cluster.rowCount - resourceIds.length) *
|
|
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
|
|
2
|
|
: 0);
|
|
resourceIds.forEach((resourceId, index) =>
|
|
rankByResourceId.set(
|
|
resourceId,
|
|
(bucketBaseY + index * RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
|
|
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT,
|
|
),
|
|
);
|
|
idsByDepth.set(depth, resourceIds);
|
|
}
|
|
orderedIdsByClusterAndDepth.set(cluster, idsByDepth);
|
|
}
|
|
|
|
for (let round = 0; round < DEPENDENCY_ORDERING_SCAN_ROUNDS; round += 1) {
|
|
for (const scanDirection of ['forward', 'reverse'] as const) {
|
|
const flowSignalByFlow = new Map<DependencyFlow, number | null>();
|
|
for (const flow of topologyForSection.flows) {
|
|
const signalResourceIds =
|
|
scanDirection === 'forward'
|
|
? flow.sourceResourceIds
|
|
: flow.targetResourceIds;
|
|
flowSignalByFlow.set(
|
|
flow,
|
|
median(
|
|
signalResourceIds.flatMap((resourceId) => {
|
|
const rank = rankByResourceId.get(resourceId);
|
|
return rank === undefined ? [] : [rank];
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
for (const cluster of clusters) {
|
|
if (!cluster.related) {
|
|
continue;
|
|
}
|
|
const idsByDepth = orderedIdsByClusterAndDepth.get(cluster)!;
|
|
const depths = Array.from(idsByDepth.keys()).sort((left, right) =>
|
|
scanDirection === 'forward' ? left - right : right - left,
|
|
);
|
|
for (const depth of depths) {
|
|
const resourceIds = idsByDepth.get(depth)!;
|
|
sortDependencyBucket(
|
|
resourceIds,
|
|
resourceById,
|
|
rankByResourceId,
|
|
scanDirection,
|
|
topologyForSection,
|
|
flowSignalByFlow,
|
|
);
|
|
resourceIds.forEach((resourceId, index) =>
|
|
rankByResourceId.set(
|
|
resourceId,
|
|
(cluster.baseY +
|
|
(cluster.related
|
|
? ((cluster.rowCount - resourceIds.length) *
|
|
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
|
|
2
|
|
: 0) +
|
|
index * RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
|
|
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const occupancy = new ResourceCanvasOccupancyIndex([...preservedPositions]);
|
|
const positions: ProjectResourceCanvasPosition[] = [];
|
|
for (const cluster of clusters) {
|
|
const idsByDepth = orderedIdsByClusterAndDepth.get(cluster)!;
|
|
for (const [depth, resourceIds] of Array.from(idsByDepth).sort(
|
|
([left], [right]) => left - right,
|
|
)) {
|
|
const normalizedDepth = Number.isFinite(depth)
|
|
? Math.max(0, Math.round(depth))
|
|
: 0;
|
|
const x =
|
|
Math.min(normalizedDepth, RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN) *
|
|
RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH;
|
|
const bucketBaseY =
|
|
cluster.baseY +
|
|
(cluster.related
|
|
? ((cluster.rowCount - resourceIds.length) *
|
|
RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) /
|
|
2
|
|
: 0);
|
|
for (let index = 0; index < resourceIds.length; index += 1) {
|
|
const resourceId = resourceIds[index]!;
|
|
let y = bucketBaseY + index * RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT;
|
|
while (occupancy.overlaps(x, y)) {
|
|
y += RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT;
|
|
}
|
|
const position: ProjectResourceCanvasPosition = {
|
|
resourceId,
|
|
section,
|
|
x,
|
|
y,
|
|
manuallyPlaced: false,
|
|
};
|
|
positions.push(position);
|
|
occupancy.add(position);
|
|
}
|
|
}
|
|
}
|
|
return positions;
|
|
}
|
|
|
|
export function reconcileResourceCanvasLayout(
|
|
source: ProjectResourceCanvasLayout,
|
|
resources: ResourceCanvasItem[],
|
|
topology?: ResourceCanvasLayoutTopology,
|
|
): ReconciledResourceCanvasLayout {
|
|
const resourceById = new Map(
|
|
resources.map((resource) => [resource.id, resource]),
|
|
);
|
|
const positionsBySection = new Map(
|
|
sectionOrder.map((section) => [
|
|
section,
|
|
[] as ProjectResourceCanvasPosition[],
|
|
]),
|
|
);
|
|
const preserved = source.positions.filter((position) => {
|
|
const resource = resourceById.get(position.resourceId);
|
|
const keep = resource?.category === position.section;
|
|
if (keep) {
|
|
positionsBySection.get(position.section)?.push(position);
|
|
}
|
|
return keep;
|
|
});
|
|
const preservedIds = new Set(
|
|
preserved.map((position) => position.resourceId),
|
|
);
|
|
const newResourcesBySection = new Map(
|
|
sectionOrder.map((section) => [section, [] as ResourceCanvasItem[]]),
|
|
);
|
|
for (const resource of resources) {
|
|
if (!preservedIds.has(resource.id)) {
|
|
newResourcesBySection.get(resource.category)?.push(resource);
|
|
}
|
|
}
|
|
|
|
for (const section of sectionOrder) {
|
|
const sectionPositions = positionsBySection.get(section) ?? [];
|
|
const sectionResources = resources.filter(
|
|
(resource) => resource.category === section,
|
|
);
|
|
let nextTypeSlot = 0;
|
|
const newResources = (newResourcesBySection.get(section) ?? []).sort(
|
|
(left, right) => compareResources(source.mode, left, right),
|
|
);
|
|
if (source.mode === 'dependency') {
|
|
const automaticPositions = dependencyAutomaticPositions(
|
|
section,
|
|
sectionResources,
|
|
newResources,
|
|
sectionPositions,
|
|
topology,
|
|
);
|
|
sectionPositions.push(...automaticPositions);
|
|
continue;
|
|
}
|
|
const occupancy = new ResourceCanvasOccupancyIndex(sectionPositions);
|
|
for (const resource of newResources) {
|
|
const placement = defaultTypePosition(occupancy, nextTypeSlot);
|
|
const point = placement.point;
|
|
nextTypeSlot = placement.nextSlot;
|
|
const position: ProjectResourceCanvasPosition = {
|
|
resourceId: resource.id,
|
|
section,
|
|
x: point.x,
|
|
y: point.y,
|
|
manuallyPlaced: false,
|
|
};
|
|
sectionPositions.push(position);
|
|
occupancy.add(position);
|
|
}
|
|
}
|
|
|
|
const ordered = sectionOrder.flatMap((section) =>
|
|
(positionsBySection.get(section) ?? []).sort(
|
|
(left, right) =>
|
|
left.y - right.y ||
|
|
left.x - right.x ||
|
|
left.resourceId.localeCompare(right.resourceId),
|
|
),
|
|
);
|
|
return {
|
|
layout: {
|
|
...source,
|
|
positions: ordered,
|
|
},
|
|
changed: !positionsEqual(source.positions, ordered),
|
|
};
|
|
}
|
|
|
|
export function moveResourceCanvasPosition(
|
|
layout: ProjectResourceCanvasLayout,
|
|
resourceId: string,
|
|
section: ProjectResourceCanvasSection,
|
|
x: number,
|
|
y: number,
|
|
): ProjectResourceCanvasLayout {
|
|
const finiteX = Number.isFinite(x) ? x : 0;
|
|
const finiteY = Number.isFinite(y) ? y : 0;
|
|
const normalizedX = Math.min(
|
|
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
|
Math.max(0, Math.round(finiteX)),
|
|
);
|
|
const normalizedY = Math.min(
|
|
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
|
Math.max(0, Math.round(finiteY)),
|
|
);
|
|
return {
|
|
...layout,
|
|
positions: layout.positions.map((position) =>
|
|
position.resourceId === resourceId && position.section === section
|
|
? {
|
|
...position,
|
|
x: normalizedX,
|
|
y: normalizedY,
|
|
manuallyPlaced: true,
|
|
}
|
|
: position,
|
|
),
|
|
};
|
|
}
|
|
|
|
export function resourceCanvasSectionExtent(
|
|
positions: ProjectResourceCanvasPosition[],
|
|
) {
|
|
return {
|
|
width: Math.max(
|
|
RESOURCE_CANVAS_SECTION_MIN_WIDTH,
|
|
...positions.map(
|
|
(position) =>
|
|
position.x + RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
|
|
),
|
|
),
|
|
height: Math.max(
|
|
RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
|
|
...positions.map(
|
|
(position) =>
|
|
position.y + RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP,
|
|
),
|
|
),
|
|
};
|
|
}
|