diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs index c3b0bb6bd..7580e4331 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs @@ -4378,6 +4378,17 @@ fn recover_asset_canvas_transaction_locked( event, )); } + if find_superseding_asset_canvas_commit( + root, + &journal, + ¤t_manifest, + ¤t_manifest_sha, + ¤t_revision, + ¤t_revision_sha, + )? { + return mark_asset_canvas_superseded_locked(root, journal, ledger) + .map(|outcome| (outcome, None)); + } return mark_asset_canvas_reconciliation_locked(root, journal, ledger) .map(|outcome| (outcome, None)); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs index 4073066f4..2dbab8594 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs @@ -1060,6 +1060,65 @@ fn recovery_marks_interrupted_refine_commit_superseded_by_verified_later_commit( commit_asset_canvas_at(fixture.root(), &third_input).expect("commit after superseded recovery"); } +#[test] +fn recovery_marks_completed_refine_commit_superseded_by_verified_later_commit() { + let (fixture, first_commit_id, second_commit_id) = + initialize_refine_fixture_with_later_commit(); + // initialize_refine_fixture_with_later_commit only forces the first + // transaction into the unresolved state. Restore both completed ledgers so + // this test models two successful commits followed by a restart. + for commit_id in [&first_commit_id, &second_commit_id] { + let mut journal = read_asset_canvas_journal(fixture.root(), commit_id) + .expect("read completed transaction journal") + .expect("completed transaction journal"); + let mut ledger = read_asset_canvas_ledger(fixture.root(), commit_id) + .expect("read completed transaction ledger") + .expect("completed transaction ledger"); + journal.stage = AssetCanvasTransactionStage::Committed; + ledger.status = AssetCanvasLedgerStatus::Committed; + write_asset_canvas_journal(fixture.root(), &journal) + .expect("restore completed journal stage"); + write_asset_canvas_ledger(fixture.root(), &ledger) + .expect("restore completed ledger status"); + } + + let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("recover two completed refine transactions"); + let first_outcome = recovered + .result + .outcomes + .iter() + .find(|outcome| outcome.commit_id == first_commit_id) + .expect("first completed outcome"); + let second_outcome = recovered + .result + .outcomes + .iter() + .find(|outcome| outcome.commit_id == second_commit_id) + .expect("second completed outcome"); + assert_eq!( + first_outcome.status, + RecoverAssetCanvasOutcomeStatus::Superseded + ); + assert_eq!( + second_outcome.status, + RecoverAssetCanvasOutcomeStatus::AlreadyCommitted + ); + assert!(!recovered.result.outcomes.iter().any(|outcome| { + outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired + })); + + let repeated = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("repeat recovery after supersede"); + assert!(repeated.result.outcomes.iter().any(|outcome| { + outcome.commit_id == first_commit_id + && outcome.status == RecoverAssetCanvasOutcomeStatus::Superseded + })); + assert!(!repeated.result.outcomes.iter().any(|outcome| { + outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired + })); +} + #[test] fn recovery_does_not_supersede_when_later_transaction_identity_is_broken() { let (fixture, first_commit_id, second_commit_id) = diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs index 8c587703c..8c87f9403 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs @@ -12,7 +12,8 @@ const RESOURCE_LAYOUT_LOCK_PATH: &str = ".agent/workbench/resource-layouts/.layo const RESOURCE_LAYOUT_MAX_BYTES: usize = 2 * 1024 * 1024; const RESOURCE_LAYOUT_MAX_POSITIONS: usize = 4096; const RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS: usize = 512; -const RESOURCE_LAYOUT_MAX_COORDINATE: u32 = 1_000_000; +const RESOURCE_LAYOUT_MIN_COORDINATE: i32 = -1_000_000; +const RESOURCE_LAYOUT_MAX_COORDINATE: i32 = 1_000_000; const RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS: usize = 100; const RESOURCE_LAYOUT_LOCK_WAIT_MILLIS: u64 = 10; @@ -337,11 +338,13 @@ fn validate_resource_layout_positions( if !resource_ids.insert(resource_id) { return Err("资源布局 resourceId 不能重复".to_string()); } - if position.x > RESOURCE_LAYOUT_MAX_COORDINATE + if position.x < RESOURCE_LAYOUT_MIN_COORDINATE + || position.x > RESOURCE_LAYOUT_MAX_COORDINATE + || position.y < RESOURCE_LAYOUT_MIN_COORDINATE || position.y > RESOURCE_LAYOUT_MAX_COORDINATE { return Err(format!( - "资源布局坐标不能超过 {RESOURCE_LAYOUT_MAX_COORDINATE}" + "资源布局坐标必须在 {RESOURCE_LAYOUT_MIN_COORDINATE} 到 {RESOURCE_LAYOUT_MAX_COORDINATE} 之间" )); } } @@ -526,7 +529,7 @@ mod tests { )) } - fn layout_position(id: &str, x: u32) -> ProjectResourceCanvasPosition { + fn layout_position(id: &str, x: i32) -> ProjectResourceCanvasPosition { ProjectResourceCanvasPosition { resource_id: id.to_string(), section: shared_contracts::game_creation_app::ProjectResourceCanvasSection::Art, @@ -883,11 +886,27 @@ mod tests { RESOURCE_LAYOUT_MAX_COORDINATE ); - let coordinate_error = update_project_resource_canvas_layout_at( + let lower_boundary = update_project_resource_canvas_layout_at( &root, ProjectResourceCanvasLayoutMode::Type, "layout-invalid", boundary.layout.revision, + vec![layout_position( + "asset-coordinate-lower-boundary", + RESOURCE_LAYOUT_MIN_COORDINATE, + )], + ) + .expect("lower boundary coordinate must remain valid"); + assert_eq!( + lower_boundary.layout.positions[0].x, + RESOURCE_LAYOUT_MIN_COORDINATE + ); + + let coordinate_error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Type, + "layout-invalid", + lower_boundary.layout.revision, vec![layout_position( "asset-coordinate", RESOURCE_LAYOUT_MAX_COORDINATE + 1, @@ -895,6 +914,18 @@ mod tests { ) .expect_err("oversized coordinate must fail"); assert!(coordinate_error.contains("坐标")); + let lower_coordinate_error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Type, + "layout-invalid", + lower_boundary.layout.revision, + vec![layout_position( + "asset-coordinate-below-lower-boundary", + RESOURCE_LAYOUT_MIN_COORDINATE - 1, + )], + ) + .expect_err("coordinate below lower boundary must fail"); + assert!(lower_coordinate_error.contains("坐标")); let id_error = update_project_resource_canvas_layout_at( &root, ProjectResourceCanvasLayoutMode::Type, diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index e44895373..6dc7e0c63 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -4878,6 +4878,11 @@ iframe.preview-frame { box-shadow: var(--platform-panel-shadow); } +.game-workbench-stage > .game-resource-manager { + grid-row: 3; + min-height: 0; +} + .game-workbench-chat { border-color: var(--platform-line-soft); box-shadow: var(--platform-nav-active-shadow); @@ -5005,6 +5010,7 @@ iframe.preview-frame { .game-resource-manager { display: flex; flex-direction: column; + flex: 1 1 auto; min-height: 0; } @@ -5081,6 +5087,8 @@ iframe.preview-frame { .game-resource-canvas { position: relative; flex: 1; + display: flex; + flex-direction: column; min-height: 0; padding: 12px; overflow: auto; @@ -5101,6 +5109,17 @@ iframe.preview-frame { cursor: grabbing; } +.game-resource-canvas--paged { + overflow: hidden; + cursor: grab; + overscroll-behavior: none; + touch-action: none; +} + +.game-resource-canvas--paged:active { + cursor: grabbing; +} + .game-resource-canvas-content { position: relative; display: grid; @@ -5116,6 +5135,190 @@ iframe.preview-frame { min-height: 0; } +.game-resource-canvas-content--paged { + display: block; + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} + +.game-resource-page-shell { + display: grid; + grid-template-columns: 212px minmax(0, 1fr); + gap: 14px; + width: 100%; + height: 100%; +} + +.game-resource-outline { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 0; + padding: 12px 2px; + overflow: auto; + border-right: 1px solid #eaded8; +} + +.game-resource-outline button { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + align-items: center; + gap: 6px; + min-height: 34px; + padding: 0 9px; + border: 0; + border-radius: 8px; + background: transparent; + color: #725348; + cursor: pointer; + text-align: left; +} + +.game-resource-outline button:hover, +.game-resource-outline button:focus-visible, +.game-resource-outline button.is-active { + background: #f6ece6; + color: #a9502a; + outline: 0; +} + +.game-resource-outline button strong { + overflow: hidden; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-resource-outline button small { + color: #a98a7c; + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.game-resource-page { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 10px; + height: 100%; + min-width: 0; + min-height: 0; +} + +.game-resource-page-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 36px; + padding: 0 4px; +} + +.game-resource-page-heading span { + display: inline-flex; + align-items: center; + gap: 8px; + color: #6d4b3f; + font-size: 13px; +} + +.game-resource-page-heading strong { + font-size: 15px; +} + +.game-resource-page-heading small { + color: #a98a7c; + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.game-resource-page-canvas { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + border: 1px solid #ecdfd8; + border-radius: 12px; + background: + radial-gradient(#eaded8 0.8px, transparent 0.8px) 0 0 / 18px 18px, + #fffdfa; +} + +.game-resource-canvas-reset { + position: absolute; + top: 12px; + right: 12px; + z-index: 3; + display: grid; + width: 30px; + height: 30px; + padding: 0; + border: 1px solid rgb(227 207 197 / 88%); + border-radius: 8px; + background: rgb(255 253 250 / 88%); + color: #8c6252; + cursor: pointer; + place-items: center; + box-shadow: 0 4px 12px rgb(112 70 52 / 10%); + backdrop-filter: blur(4px); +} + +.game-resource-canvas-reset:hover, +.game-resource-canvas-reset:focus-visible { + border-color: #ce7650; + color: #bd5f37; + outline: 2px solid rgb(206 118 80 / 22%); + outline-offset: 1px; +} + +.game-resource-page-world { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; +} + +.game-resource-page-empty { + position: absolute; + inset: 0; + display: grid; + padding: 24px; + color: #b0958a; + font-size: 12px; + place-items: center; + pointer-events: none; +} + +.game-resource-next-page { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 38px; + padding: 0 14px; + border: 1px solid #e8d6cc; + border-radius: 10px; + background: #fff8f4; + color: #725348; + cursor: pointer; +} + +.game-resource-next-page:hover, +.game-resource-next-page:focus-visible { + border-color: #d6946f; + color: #a9502a; + outline: 0; +} + +.game-resource-next-page span { + color: #a98a7c; + font-size: 10px; +} + +.game-resource-next-page strong { + font-size: 12px; +} + .game-resource-section-stack { display: contents; } @@ -5374,6 +5577,18 @@ iframe.preview-frame { transform: translate3d(var(--resource-x, 0), var(--resource-y, 0), 0); } +.game-resource-card { + cursor: grab; + touch-action: none; +} + +.game-resource-card.is-dragging { + cursor: grabbing; + box-shadow: + 0 14px 32px rgb(195 105 62 / 24%), + 0 0 0 2px rgb(216 115 66 / 24%); +} + .game-resource-card:hover, .game-resource-card:focus-within, .game-resource-card.is-selected { diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 0cab0714c..a46a098d9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -12,20 +12,15 @@ import { Image, Info, ListFilter, - Maximize2, - Minus, Music2, Pause, Play, - Plus, RotateCcw, Search, Settings2, SlidersHorizontal, Sparkles, X, - ZoomIn, - ZoomOut, } from 'lucide-react'; import { type CSSProperties, @@ -76,9 +71,13 @@ import { type ResourceFocusIntent, } from './projectResourceLiveUpdateModel'; import { - constrainResourceCanvasViewport, createResourceCanvasCardSizeByResourceId, - RESOURCE_CANVAS_BOUNDARY_PADDING, + normalizeInfiniteResourceCanvasViewport, + RESOURCE_CANVAS_DRAG_THRESHOLD, + RESOURCE_CANVAS_FIT_PADDING, + RESOURCE_CANVAS_SECTION_MIN_HEIGHT, + RESOURCE_CANVAS_SECTION_MIN_WIDTH, + RESOURCE_CANVAS_SECTION_ORDER, type ResourceCanvasCardSize, resourceCanvasCardSize, resourceCanvasSectionExtent, @@ -95,7 +94,10 @@ import { type ProjectResourceGraphNodeInput, type ProjectResourceGraphReadModel, } from './resourceDependencyGraphModel'; -import { ResourceDependencyOverlay } from './ResourceDependencyOverlay'; +import { + ResourceDependencyOverlay, + type ResourceDependencyOverlayHandle, +} from './ResourceDependencyOverlay'; import { defaultDerivedResourceName, type ProjectResourceEditCapability, @@ -278,19 +280,52 @@ export type ProjectAgentRuntimeSummary = { const emptyProjectAgentRuntimeSummaries: ProjectAgentRuntimeSummary[] = []; const emptyProjectAgentResults: ProjectAgentResultSummary[] = []; -const RESOURCE_DEPENDENCY_SECTION_HEADER_HEIGHT = 48; -const RESOURCE_DEPENDENCY_SECTION_GAP = 56; +type ResourceCanvasViewportByCategory = Record< + ResourceCategory, + CanvasViewport +>; + +function defaultResourceCanvasViewports(): ResourceCanvasViewportByCategory { + return { + document: { x: 48, y: 48, scale: 1 }, + art: { x: 48, y: 48, scale: 1 }, + audio: { x: 48, y: 48, scale: 1 }, + code: { x: 48, y: 48, scale: 1 }, + version: { x: 48, y: 48, scale: 1 }, + }; +} const RESOURCE_CANVAS_FALLBACK_WIDTH = 900; const RESOURCE_CANVAS_FALLBACK_HEIGHT = 640; +const RESOURCE_CANVAS_MIN_DRAG_COORDINATE = -1_000_000; +const RESOURCE_CANVAS_MAX_DRAG_COORDINATE = 1_000_000; + +function normalizeResourceCanvasDragCoordinate(value: number) { + return Math.min( + RESOURCE_CANVAS_MAX_DRAG_COORDINATE, + Math.max(RESOURCE_CANVAS_MIN_DRAG_COORDINATE, Math.round(value)), + ); +} + +function resourceCanvasViewportElement(element: HTMLElement | null) { + const pageCanvas = element?.querySelector( + '.game-resource-page-canvas', + ); + return pageCanvas ?? element; +} function resourceCanvasElementSize(element: HTMLElement | null) { - const rect = element?.getBoundingClientRect(); + const viewportElement = resourceCanvasViewportElement(element); + const rect = viewportElement?.getBoundingClientRect(); return { width: - element?.clientWidth || rect?.width || RESOURCE_CANVAS_FALLBACK_WIDTH, + viewportElement?.clientWidth || + rect?.width || + RESOURCE_CANVAS_FALLBACK_WIDTH, height: - element?.clientHeight || rect?.height || RESOURCE_CANVAS_FALLBACK_HEIGHT, + viewportElement?.clientHeight || + rect?.height || + RESOURCE_CANVAS_FALLBACK_HEIGHT, }; } @@ -325,13 +360,7 @@ export type ProjectDevelopmentViewProps = { ) => void; }; -const categoryOrder: ResourceCategory[] = [ - 'code', - 'document', - 'version', - 'art', - 'audio', -]; +const categoryOrder = RESOURCE_CANVAS_SECTION_ORDER; const categoryLabels: Record = { code: '游戏代码', @@ -463,11 +492,16 @@ const ResourceCard = memo(function ResourceCard({ relationState, x, y, + dragging, previewIdentity, preview, cardSize, activeMediaIdentity, onSelect, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, onObservePreview, onRequestPreview, onPreviewDecodeError, @@ -480,11 +514,21 @@ const ResourceCard = memo(function ResourceCard({ relationState: 'version-binding' | null; x: number; y: number; + dragging: boolean; previewIdentity: string; preview: ProjectResourceCardPreviewState; cardSize: ResourceCanvasCardSize; activeMediaIdentity: string | null; onSelect: (resourceId: string) => void; + onPointerDown: ( + event: ReactPointerEvent, + resource: ProjectResource, + x: number, + y: number, + ) => void; + onPointerMove: (event: ReactPointerEvent) => void; + onPointerUp: (event: ReactPointerEvent) => void; + onPointerCancel: (event: ReactPointerEvent) => void; onObservePreview: ( element: HTMLElement, resource: ProjectResource, @@ -694,7 +738,7 @@ const ResourceCard = memo(function ResourceCard({ ref={cardRef} className={`game-resource-card${selected ? ' is-selected' : ''}${ relationState ? ` is-relation-${relationState}` : '' - }`} + }${dragging ? ' is-dragging' : ''}`} data-preview-kind={kind} style={ { @@ -704,6 +748,11 @@ const ResourceCard = memo(function ResourceCard({ '--resource-card-height': `${cardSize.height}px`, } as CSSProperties } + onPointerDown={(event) => onPointerDown(event, resource, x, y)} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerCancel} + onClick={() => onSelect(resource.id)} >