Merge remote-tracking branch 'origin/master' into codex/public-work-readmodel-smooth-transition

This commit is contained in:
kdletters
2026-05-26 16:38:38 +08:00
130 changed files with 2966 additions and 511 deletions

View File

@@ -447,7 +447,10 @@ function settleMatchedTrayItems(
};
}
export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
export function startLocalMatch3DRun(
clearCount = 12,
profileId = 'local-match3d-profile',
): Match3DRunSnapshot {
const normalizedClearCount =
normalizeLocalMatch3DRuntimeClearCount(clearCount);
const selectedSeeds = selectVisualSeeds(normalizedClearCount);
@@ -467,7 +470,7 @@ export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
const nowMs = Date.now();
return {
runId: `local-match3d-run-${nowMs}`,
profileId: 'local-match3d-profile',
profileId,
status: 'Running',
snapshotVersion: 1,
startedAtMs: nowMs,

View File

@@ -95,7 +95,7 @@ test('local Match3D runtime adapter exposes the same runtime seam as the server
const started = await adapter.startRun('ignored-local-profile');
const clickableItem = started.run.items.find((item) => item.clickable);
expect(started.run.profileId).toBe('local-match3d-profile');
expect(started.run.profileId).toBe('ignored-local-profile');
expect(clickableItem).toBeTruthy();
const clickResult = await adapter.clickItem(started.run.runId, {
@@ -117,6 +117,15 @@ test('local Match3D runtime adapter exposes the same runtime seam as the server
expect(stopped.run.status).toBe('Stopped');
});
test('local Match3D runtime adapter keeps the requested profile id on restart', async () => {
const adapter = createLocalMatch3DRuntimeAdapter({ clearCount: 1 });
const started = await adapter.startRun('match3d-demo-20260525');
const restarted = await adapter.restartRun(started.run.runId);
expect(started.run.profileId).toBe('match3d-demo-20260525');
expect(restarted.run.profileId).toBe('match3d-demo-20260525');
});
test('local Match3D runtime adapter keeps authority run local to the adapter', async () => {
const adapter = createLocalMatch3DRuntimeAdapter({ initialRun: startLocalMatch3DRun(1) });
const first = await adapter.getRun('unused-run-id');

View File

@@ -36,6 +36,7 @@ export type Match3DRuntimeAdapter = {
export type LocalMatch3DRuntimeAdapterOptions = {
clearCount?: number;
profileId?: string;
initialRun?: Match3DRunResponse['run'];
};
@@ -74,11 +75,16 @@ export function createServerMatch3DRuntimeAdapter(
export function createLocalMatch3DRuntimeAdapter(
options: LocalMatch3DRuntimeAdapterOptions = {},
): Match3DRuntimeAdapter {
let authorityRun = options.initialRun ?? startLocalMatch3DRun(options.clearCount);
let authorityRun =
options.initialRun ??
startLocalMatch3DRun(options.clearCount, options.profileId);
return {
async startRun() {
authorityRun = startLocalMatch3DRun(options.clearCount);
async startRun(profileId) {
authorityRun = startLocalMatch3DRun(
options.clearCount,
profileId || options.profileId,
);
return { run: authorityRun };
},
async getRun() {
@@ -91,7 +97,10 @@ export function createLocalMatch3DRuntimeAdapter(
return result;
},
async restartRun() {
authorityRun = startLocalMatch3DRun(options.clearCount);
authorityRun = startLocalMatch3DRun(
options.clearCount,
authorityRun.profileId || options.profileId,
);
return { run: authorityRun };
},
async stopRun() {

View File

@@ -36,6 +36,42 @@ describe('match3dSpritesheetParser', () => {
]);
});
test('同一行图标高度错位时仍按行内横向顺序映射标签', () => {
const width = 36;
const height = 24;
const alpha = new Uint8ClampedArray(width * height);
const paint = (x0: number, y0: number, x1: number, y1: number) => {
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) {
alpha[y * width + x] = 255;
}
}
};
paint(2, 1, 5, 4);
paint(12, 1, 15, 4);
paint(22, 1, 25, 4);
paint(2, 15, 5, 20);
paint(12, 13, 15, 18);
paint(22, 10, 25, 15);
const regions = detectMatch3DSpritesheetRegions({
alpha,
width,
height,
labels: ['返回', '设置', '方格', '移出', '凑齐', '打乱'],
});
expect(regions.map((region) => `${region.label}:${region.x}`)).toEqual([
'返回:2',
'设置:12',
'方格:22',
'移出:2',
'凑齐:12',
'打乱:22',
]);
});
test('忽略小噪点,只返回可用矩形素材', () => {
const width = 8;
const height = 8;

View File

@@ -79,8 +79,7 @@ export function detectMatch3DSpritesheetRegions({
}
}
return components
.sort((left, right) => left.y - right.y || left.x - right.x)
return sortMatch3DSpritesheetComponentsByRows(components)
.map((component, index) => ({
label: labels[index] ?? `素材${index + 1}`,
x: component.x,
@@ -90,6 +89,67 @@ export function detectMatch3DSpritesheetRegions({
}));
}
function sortMatch3DSpritesheetComponentsByRows(
components: Match3DDetectedComponent[],
) {
const rows: Array<{
top: number;
bottom: number;
components: Match3DDetectedComponent[];
}> = [];
[...components]
.sort(
(left, right) =>
resolveMatch3DSpritesheetComponentCenterY(left) -
resolveMatch3DSpritesheetComponentCenterY(right) ||
left.x - right.x,
)
.forEach((component) => {
const row = rows.find((entry) =>
isMatch3DSpritesheetComponentInRow(component, entry),
);
if (!row) {
rows.push({
top: component.y,
bottom: component.y + component.height - 1,
components: [component],
});
return;
}
row.top = Math.min(row.top, component.y);
row.bottom = Math.max(row.bottom, component.y + component.height - 1);
row.components.push(component);
});
return rows
.sort((left, right) => left.top - right.top)
.flatMap((row) =>
row.components.sort((left, right) => left.x - right.x || left.y - right.y),
);
}
function resolveMatch3DSpritesheetComponentCenterY(
component: Match3DDetectedComponent,
) {
return component.y + component.height / 2;
}
function isMatch3DSpritesheetComponentInRow(
component: Match3DDetectedComponent,
row: { top: number; bottom: number },
) {
const bottom = component.y + component.height - 1;
if (component.y <= row.bottom && bottom >= row.top) {
return true;
}
const gap =
component.y > row.bottom ? component.y - row.bottom : row.top - bottom;
return gap <= Math.max(2, component.height * 0.25);
}
export function buildMatch3DItemSpritesheetViewRegions<
Region extends Match3DSpritesheetRegion,
>(