diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx
index c9cc82224..a8b5bfa59 100644
--- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx
@@ -34,6 +34,7 @@ import type { Node as UiNode } from '../../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
import type { UiEditorCanvasProjection } from '../../useUiEditorPage';
import { UiNodeContextMenu } from '../UiNodeContextMenu';
+import { resolvePreviewGridStep } from './previewGrid';
import {
handlePreviewZoomKeyDown,
isPreviewZoomInteractiveTarget,
@@ -239,6 +240,7 @@ export function PreviewWorkspace({
}, [scaleViewportFromCenter]);
const displayPercent = formatCanvasDisplayScalePercent(viewport.scale);
+ const previewGridStep = resolvePreviewGridStep(viewport.scale);
const zoomToDisplayScale = useCallback(
(displayScale: number) => {
@@ -479,7 +481,7 @@ export function PreviewWorkspace({
className="size-full select-none outline-none bg-[radial-gradient(#b8aaa0_1.5px,transparent_1.5px)]"
style={{
backgroundPosition: `${viewport.x}px ${viewport.y}px`,
- backgroundSize: `${28 * viewport.scale}px ${28 * viewport.scale}px`,
+ backgroundSize: `${previewGridStep * viewport.scale}px ${previewGridStep * viewport.scale}px`,
}}
role="region"
aria-label="UI 预览画布"
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewGrid.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewGrid.ts
new file mode 100644
index 000000000..d2ab93f2a
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewGrid.ts
@@ -0,0 +1,26 @@
+/** 背景网格的基础世界步长;缩放时按 2 倍档位调整屏幕密度。 */
+export const PREVIEW_GRID_BASE_STEP = 28;
+const PREVIEW_GRID_MIN_SCREEN_SPACING = 20;
+const PREVIEW_GRID_MAX_SCREEN_SPACING = 40;
+
+/**
+ * 选择一个离散的世界步长,让网格圆点在屏幕上保持可读密度。
+ * 背景位置仍使用 viewport 的屏幕平移量,因此切档不会破坏世界原点对齐。
+ */
+export function resolvePreviewGridStep(viewportScale: number): number {
+ if (!Number.isFinite(viewportScale) || viewportScale <= 0) {
+ return PREVIEW_GRID_BASE_STEP;
+ }
+
+ let step = PREVIEW_GRID_BASE_STEP;
+ let screenSpacing = step * viewportScale;
+ while (screenSpacing < PREVIEW_GRID_MIN_SCREEN_SPACING) {
+ step *= 2;
+ screenSpacing *= 2;
+ }
+ while (screenSpacing >= PREVIEW_GRID_MAX_SCREEN_SPACING) {
+ step /= 2;
+ screenSpacing /= 2;
+ }
+ return step;
+}
diff --git a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx
index 92abe5778..6f756a6c6 100644
--- a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx
+++ b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx
@@ -5,6 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Node as UiNode } from '../src/features/ui-editor/types/Node';
import type { UIDesignImage } from '../src/features/ui-editor/types/UIDesignImage';
+import {
+ PREVIEW_GRID_BASE_STEP,
+ resolvePreviewGridStep,
+} from '../src/view/ui-editor/components/preview/previewGrid';
import { PreviewWorkspace } from '../src/view/ui-editor/components/preview/PreviewWorkspace';
import type { UiEditorCanvasProjection } from '../src/view/ui-editor/useUiEditorPage';
@@ -41,6 +45,10 @@ const root: UiNode = {
component: null,
children_display_mode: 'Stack',
children: [],
+ offset: {
+ min: [0, 0],
+ max: [1200, 800],
+ },
};
const activeImage: UIDesignImage = {
@@ -62,6 +70,7 @@ function createCanvas(
isLocked: false,
activeImage,
activeImageId: 'page-1',
+ uiTrees: [{ src_ui_design: 'page-1', root }],
previewUrls: { 'page-1': 'data:image/png;base64,' },
images: { 'page-1': activeImage },
sprites: {},
@@ -111,6 +120,31 @@ afterEach(() => {
});
describe('PreviewWorkspace quick zoom', () => {
+ it('chooses doubled or halved world steps to keep the grid readable', () => {
+ expect(resolvePreviewGridStep(1)).toBe(PREVIEW_GRID_BASE_STEP);
+ expect(resolvePreviewGridStep(0.5)).toBe(56);
+ expect(resolvePreviewGridStep(0.125)).toBe(224);
+ expect(resolvePreviewGridStep(2)).toBe(14);
+ expect(resolvePreviewGridStep(4)).toBe(7);
+
+ for (const scale of [0.125, 0.25, 0.5, 1, 2, 4, 8]) {
+ const spacing = resolvePreviewGridStep(scale) * scale;
+ expect(spacing).toBeGreaterThanOrEqual(20);
+ expect(spacing).toBeLessThan(40);
+ }
+ });
+
+ it('applies the adaptive screen-space spacing to the preview background', () => {
+ const rendered = render();
+ const preview = screen.getByRole('region', { name: 'UI 预览画布' });
+ const scale = logicalViewportScale(rendered.container);
+ const [backgroundWidth] = preview.style.backgroundSize.split('px');
+
+ expect(Number(backgroundWidth)).toBeCloseTo(
+ resolvePreviewGridStep(scale) * scale,
+ );
+ });
+
it('uses the toolbar zoom step while the preview is hovered', () => {
const rendered = render();
const preview = screen.getByRole('region', { name: 'UI 预览画布' });
diff --git a/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md
index 5d35bf013..7b21c9a35 100644
--- a/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md
+++ b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md
@@ -20,6 +20,7 @@
- 仅在存在可缩放视口且确认命中快捷键时调用 `preventDefault()` 与 `stopPropagation()`,防止浏览器页面同时缩放。
- 保留现有 `Ctrl/Cmd+0` 适配画布与 `Ctrl/Cmd+1` 恢复 100% 行为,不增加其他重置快捷键。缩放仍是当前预览实例的临时 UI 状态。
- 快捷键和百分比使用逻辑 `viewport.scale` 作为缩放真相;共享 `CanvasWorld` 的渲染 transform 可能包含超采样换算,验证时不得直接把 CSS transform 值当作用户可见比例。
+- 预览画布背景继续使用圆点网格,并按 `14/28/56/112…` 世界步长的 2 倍档位自适应缩放;屏幕间距目标约为 20–40px,缩放过小时自动加倍步长、放大时自动减半步长。网格背景位置继续跟随 `viewport.x/y`,保持世界原点对齐,不改变圆点样式。
- 保留快捷键与缩放焦点边界的组件级回归测试,不扩展端到端测试。
## 验收