AGC 资源画布分区轴改为 6 类资产分类加独立项目版本栏目(阶段一:契约与读时映射)
- 契约层新增 PROJECT_RESOURCE_CANVAS_SECTIONS:6 类资产分类加末尾独立的项目版本栏目,并补齐分区类型 - 契约层新增旧四 / 五栏目取值白名单、Persisted 联合类型与两个类型守卫 - Rust 契约 ProjectResourceCanvasSection 增补 7 个现行 variant 并保留 Code / Art 旧值,新增同名分区常量 - Rust 契约不升 game-creator-resource-layout.v1 版本、不给未知 section 加兜底,损坏 payload 继续失败关闭 - 新增 resourceCanvasSectionMapping.ts:旧栏目到现行分区的映射表、回落栏目与读时归一化 - 读时归一化只改写 section,x / y / manuallyPlaced 原样保留,无法归并时沿用既有丢弃行为 - resourceProjectionModel 把扩展名分类器更名为 projectedResourceKind 并收敛为准入与显示类型 - resourceProjectionModel 新增 projectResourceCanvasCategory:项目版本独立成栏、Agent 回执归文档、其余按资产分类 - Rust resource_layout 测试夹具改用现行分区值,并新增既有旧 section sidecar 仍可读取的用例 - 新增 resourceCanvasSectionMapping 测试:旧值乘目标栏目矩阵覆盖坐标保留、section 改写、changed 判定与回落列不变量 - projectResourceProjectionModel 测试新增 7 栏分区投影、项目版本独立成栏与只登记游戏代码项目落在待归类 - 同步 PRD、GameAgent 资源自由画板技术方案、AGC 实施计划与共享记忆决策记录的分区口径
This commit is contained in:
@@ -532,7 +532,7 @@ mod tests {
|
||||
fn layout_position(id: &str, x: i32) -> ProjectResourceCanvasPosition {
|
||||
ProjectResourceCanvasPosition {
|
||||
resource_id: id.to_string(),
|
||||
section: shared_contracts::game_creation_app::ProjectResourceCanvasSection::Art,
|
||||
section: shared_contracts::game_creation_app::ProjectResourceCanvasSection::Scene,
|
||||
x,
|
||||
y: 24,
|
||||
manually_placed: true,
|
||||
@@ -941,6 +941,67 @@ mod tests {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_layout_keeps_reading_legacy_section_sidecars() {
|
||||
use serde_json::json;
|
||||
|
||||
let root = unique_resource_layout_project_path();
|
||||
init_local_game_project_at(&root, "layout-legacy", "旧栏目布局").expect("init project");
|
||||
let path = root.join(resource_layout_relative_path(
|
||||
ProjectResourceCanvasLayoutMode::Dependency,
|
||||
));
|
||||
fs::create_dir_all(path.parent().expect("layout parent")).expect("create parent");
|
||||
fs::write(
|
||||
&path,
|
||||
serde_json::to_vec(&json!({
|
||||
"schemaVersion": "game-creator-resource-layout.v1",
|
||||
"projectId": "layout-legacy",
|
||||
"mode": "dependency",
|
||||
"revision": 3,
|
||||
"positions": [
|
||||
{
|
||||
"resourceId": "asset-legacy-art",
|
||||
"section": "art",
|
||||
"x": -32,
|
||||
"y": 64,
|
||||
"manuallyPlaced": true
|
||||
},
|
||||
{
|
||||
"resourceId": "asset-legacy-code",
|
||||
"section": "code",
|
||||
"x": 180,
|
||||
"y": 0,
|
||||
"manuallyPlaced": false
|
||||
}
|
||||
],
|
||||
"updatedAt": 7
|
||||
}))
|
||||
.expect("serialize legacy layout"),
|
||||
)
|
||||
.expect("write legacy layout");
|
||||
|
||||
let layout = read_project_resource_canvas_layout_at(
|
||||
&root,
|
||||
ProjectResourceCanvasLayoutMode::Dependency,
|
||||
)
|
||||
.expect("legacy sidecar must stay readable");
|
||||
assert_eq!(layout.revision, 3);
|
||||
assert_eq!(layout.positions.len(), 2);
|
||||
assert_eq!(
|
||||
layout.positions[0].section,
|
||||
shared_contracts::game_creation_app::ProjectResourceCanvasSection::Art
|
||||
);
|
||||
assert_eq!(layout.positions[0].x, -32);
|
||||
assert_eq!(layout.positions[0].y, 64);
|
||||
assert!(layout.positions[0].manually_placed);
|
||||
assert_eq!(
|
||||
layout.positions[1].section,
|
||||
shared_contracts::game_creation_app::ProjectResourceCanvasSection::Code
|
||||
);
|
||||
assert_eq!(layout.positions[1].x, 180);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn resource_layout_rejects_symlinks_and_hardlinks() {
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
isLegacyProjectResourceCanvasSection,
|
||||
isProjectResourceCanvasCategory,
|
||||
type LegacyProjectResourceCanvasSection,
|
||||
type PersistedProjectResourceCanvasSection,
|
||||
type ProjectResourceCanvasCategory,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
|
||||
/**
|
||||
* 旧栏目值 → 现行分区集合。旧四 / 五栏目是「扩展名 + mediaType」口径,无法 1:1 归并到
|
||||
* 6 类资产分类,因此按资源当前分区做保守归并:
|
||||
*
|
||||
* - `document` / `audio` / `version` 在新旧口径下同名,1:1 保留;
|
||||
* - 旧 `art` 混合了 UI 交互、角色与对象、场景与环境与待归类,按资源当前分区落入其中之一;
|
||||
* - 旧 `code` 在 6 类资产分类里没有对应栏目,统一落入「待归类」。
|
||||
*/
|
||||
export const LEGACY_RESOURCE_CANVAS_SECTION_TARGETS: Record<
|
||||
LegacyProjectResourceCanvasSection,
|
||||
readonly ProjectResourceCanvasCategory[]
|
||||
> = {
|
||||
document: ['document'],
|
||||
audio: ['audio'],
|
||||
version: ['version'],
|
||||
art: ['ui-interaction', 'character', 'scene', 'unclassified'],
|
||||
code: ['unclassified'],
|
||||
};
|
||||
|
||||
/**
|
||||
* 无法精确归并时回落到的栏目。当前分区不在目标集合内(例如资产后来被重新分类)时,
|
||||
* 该坐标按今天「资源分区已变化」的既有行为丢弃,不做坐标迁移、不重置其余坐标。
|
||||
*/
|
||||
export const LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK: Record<
|
||||
LegacyProjectResourceCanvasSection,
|
||||
ProjectResourceCanvasCategory
|
||||
> = {
|
||||
document: 'document',
|
||||
audio: 'audio',
|
||||
version: 'version',
|
||||
art: 'unclassified',
|
||||
code: 'unclassified',
|
||||
};
|
||||
|
||||
/**
|
||||
* 把磁盘上的 `section` 值归一到现行分区。
|
||||
*
|
||||
* 返回 `null` 表示该坐标既不属于资源的现行分区、也不在旧栏目白名单内(含无法识别的取值),
|
||||
* 与今天「持久化坐标分区与资源分区不一致」的处理一致:丢弃该条坐标,不臆造新位置。
|
||||
*/
|
||||
export function resolveResourceCanvasSection(
|
||||
section: unknown,
|
||||
resourceCategory: ProjectResourceCanvasCategory,
|
||||
): ProjectResourceCanvasCategory | null {
|
||||
if (isProjectResourceCanvasCategory(section)) {
|
||||
return section === resourceCategory ? section : null;
|
||||
}
|
||||
if (!isLegacyProjectResourceCanvasSection(section)) {
|
||||
return null;
|
||||
}
|
||||
return LEGACY_RESOURCE_CANVAS_SECTION_TARGETS[section].includes(
|
||||
resourceCategory,
|
||||
)
|
||||
? resourceCategory
|
||||
: null;
|
||||
}
|
||||
|
||||
export type NormalizedResourceCanvasPosition = {
|
||||
resourceId: string;
|
||||
section: ProjectResourceCanvasCategory;
|
||||
x: number;
|
||||
y: number;
|
||||
manuallyPlaced: boolean;
|
||||
};
|
||||
|
||||
export type NormalizedResourceCanvasPositionResult = {
|
||||
position: NormalizedResourceCanvasPosition;
|
||||
/**
|
||||
* 该坐标的 `section` 是否被读时改写。改写后的布局与原布局逐项不同,
|
||||
* 既有协调路径据此判定 `changed` 并写入一次新分区的 sidecar。
|
||||
*/
|
||||
changed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 读时兼容映射:把一条持久化坐标归一到现行分区,原样保留 `x / y / manuallyPlaced`。
|
||||
*
|
||||
* 返回 `null` 表示资源不存在或该坐标无法归并,调用方不应把它放进任何分区。
|
||||
*/
|
||||
export function normalizeResourceCanvasPosition(
|
||||
position: {
|
||||
resourceId: string;
|
||||
section: PersistedProjectResourceCanvasSection;
|
||||
x: number;
|
||||
y: number;
|
||||
manuallyPlaced: boolean;
|
||||
},
|
||||
categoryByResourceId: ReadonlyMap<string, ProjectResourceCanvasCategory>,
|
||||
): NormalizedResourceCanvasPositionResult | null {
|
||||
const resourceCategory = categoryByResourceId.get(position.resourceId);
|
||||
if (resourceCategory === undefined) {
|
||||
return null;
|
||||
}
|
||||
const section = resolveResourceCanvasSection(
|
||||
position.section,
|
||||
resourceCategory,
|
||||
);
|
||||
if (section === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
position: {
|
||||
resourceId: position.resourceId,
|
||||
section,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
manuallyPlaced: position.manuallyPlaced,
|
||||
},
|
||||
changed: section !== position.section,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
gameCreationAppAssetTags,
|
||||
type GameCreationAppManifest,
|
||||
type GameIterationVersion,
|
||||
type ProjectResourceCanvasCategory,
|
||||
type ProjectResourceCanvasSection,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
|
||||
@@ -92,11 +93,19 @@ function fileName(path: string) {
|
||||
return path.split(/[\\/]/u).filter(Boolean).pop() || path;
|
||||
}
|
||||
|
||||
export function classifyProjectedResource(input: {
|
||||
export type ProjectedResourceKind = 'audio' | 'art' | 'code' | 'document';
|
||||
|
||||
/**
|
||||
* 资源类型判定:按扩展名、mediaType 与 manifest `kind` 判定资源类型。
|
||||
*
|
||||
* 只有能判定类型的资源才进入资源画布:`.zip` / `.exe` 等无法识别的二进制产物与附件返回
|
||||
* `null`,卡片显示类型复用同一口径。画布分区口径见 `projectResourceCanvasCategory`。
|
||||
*/
|
||||
export function projectedResourceKind(input: {
|
||||
path: string;
|
||||
mediaType: string;
|
||||
kind?: string;
|
||||
}): Exclude<ProjectResourceCategory, 'version'> | null {
|
||||
}): ProjectedResourceKind | null {
|
||||
const normalizedPath = input.path.trim().toLowerCase();
|
||||
const normalizedMediaType = input.mediaType.trim().toLowerCase();
|
||||
const normalizedKind = input.kind?.trim().toLowerCase() ?? '';
|
||||
@@ -196,6 +205,25 @@ export function projectResourceAssetCategory(
|
||||
return resource.assetCategory ?? 'unclassified';
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源画布分区口径:manifest 资产的 6 类功能分类,加上末尾独立的「项目版本」栏目。
|
||||
*
|
||||
* 项目版本不是资源资产,6 类资产分类轴对它不适用,固定单独成栏。Agent 文本回执是文本
|
||||
* 成果,保持在「文档」栏目。其余非 manifest 资源(任务产物、附件)没有 `category` 事实源,
|
||||
* 统一归 `待归类`,不按扩展名另造分区。
|
||||
*/
|
||||
export function projectResourceCanvasCategory(
|
||||
resource: Pick<ProjectResource, 'subtype' | 'assetCategory'>,
|
||||
): ProjectResourceCanvasCategory {
|
||||
if (resource.subtype === 'project-version') {
|
||||
return 'version';
|
||||
}
|
||||
if (resource.subtype === 'agent-result') {
|
||||
return 'document';
|
||||
}
|
||||
return projectResourceAssetCategory(resource);
|
||||
}
|
||||
|
||||
function resourcePriority(resource: ProjectResource) {
|
||||
if (resource.manifestAssetId) {
|
||||
return 4;
|
||||
@@ -222,7 +250,7 @@ export function projectResourcesFromReadModels(
|
||||
continue;
|
||||
}
|
||||
for (const path of task.artifacts) {
|
||||
const category = classifyProjectedResource({ path, mediaType: '' });
|
||||
const category = projectedResourceKind({ path, mediaType: '' });
|
||||
if (!category) {
|
||||
continue;
|
||||
}
|
||||
@@ -253,7 +281,7 @@ export function projectResourcesFromReadModels(
|
||||
}
|
||||
|
||||
for (const asset of manifest.assets) {
|
||||
const category = classifyProjectedResource({
|
||||
const category = projectedResourceKind({
|
||||
path: asset.localPath,
|
||||
mediaType: asset.mediaType,
|
||||
kind: asset.kind,
|
||||
@@ -299,7 +327,7 @@ export function projectResourcesFromReadModels(
|
||||
if (attachment.status !== 'imported' || !attachment.localPath) {
|
||||
continue;
|
||||
}
|
||||
const category = classifyProjectedResource({
|
||||
const category = projectedResourceKind({
|
||||
path: attachment.localPath,
|
||||
mediaType: attachment.mediaType,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
createGameCreationAppManifest,
|
||||
PROJECT_RESOURCE_CANVAS_SECTIONS,
|
||||
} from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
projectedResourceKind,
|
||||
projectResourceAssetCategory,
|
||||
projectResourceCanvasCategory,
|
||||
projectResourcesFromReadModels,
|
||||
projectResourceTypeLabel,
|
||||
} from '../src/view/project-development/resourceProjectionModel';
|
||||
@@ -400,3 +405,191 @@ describe('项目资源投影', () => {
|
||||
).toBe('未知');
|
||||
});
|
||||
});
|
||||
|
||||
describe('资源画布分区投影', () => {
|
||||
it('manifest 资产按功能分类分区,项目版本独立成栏,其余非资产资源归待归类', () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'canvas-partition-projection',
|
||||
'画布分区投影测试',
|
||||
);
|
||||
manifest.assets = [
|
||||
{
|
||||
id: 'ui-prototype',
|
||||
kind: 'ui-design',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/ui.png',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
{
|
||||
id: 'hero-animation',
|
||||
kind: 'character-animation',
|
||||
mediaType: 'application/json',
|
||||
localPath: 'assets/hero.json',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
{
|
||||
id: 'forest',
|
||||
kind: 'scene',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/forest.png',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
{
|
||||
id: 'theme',
|
||||
kind: 'background-music',
|
||||
mediaType: 'audio/mpeg',
|
||||
localPath: 'audio/theme.mp3',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
{
|
||||
id: 'gdd',
|
||||
kind: 'spec',
|
||||
mediaType: 'text/markdown',
|
||||
localPath: 'memory/gdd.md',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
{
|
||||
id: 'raw-illustration',
|
||||
kind: 'image',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/raw.png',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
];
|
||||
manifest.versions = [
|
||||
{
|
||||
versionId: 'version-1',
|
||||
parentVersionId: null,
|
||||
projectRevision: 1,
|
||||
resourceBindings: [],
|
||||
createdReason: 'initial',
|
||||
createdAt: 1,
|
||||
},
|
||||
];
|
||||
const designTask = manifest.tasks.find(
|
||||
(task) => task.id === 'design-foundation',
|
||||
);
|
||||
if (!designTask) {
|
||||
throw new Error('缺少资源分区测试任务');
|
||||
}
|
||||
designTask.status = 'completed';
|
||||
designTask.artifacts = ['memory/game-design.md'];
|
||||
|
||||
const resources = projectResourcesFromReadModels(
|
||||
manifest,
|
||||
[
|
||||
{
|
||||
fileName: 'rules.yaml',
|
||||
mediaType: 'application/yaml',
|
||||
localPath: 'imports/rules.yaml',
|
||||
status: 'imported',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
agentId: 'design-foundation',
|
||||
runId: 'final-run',
|
||||
label: '玩法策划 Agent',
|
||||
title: '玩法文档回执',
|
||||
content: '回执正文',
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
);
|
||||
const categoryById = new Map(
|
||||
resources.map((resource) => [
|
||||
resource.id,
|
||||
projectResourceCanvasCategory(resource),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(categoryById.get('asset:ui-prototype')).toBe('ui-interaction');
|
||||
expect(categoryById.get('asset:hero-animation')).toBe('character');
|
||||
expect(categoryById.get('asset:forest')).toBe('scene');
|
||||
expect(categoryById.get('asset:theme')).toBe('audio');
|
||||
expect(categoryById.get('asset:gdd')).toBe('document');
|
||||
expect(categoryById.get('asset:raw-illustration')).toBe('unclassified');
|
||||
expect(categoryById.get('version:version-1')).toBe('version');
|
||||
expect(categoryById.get('attachment:imports/rules.yaml')).toBe(
|
||||
'unclassified',
|
||||
);
|
||||
expect(
|
||||
categoryById.get('task:design-foundation:memory/game-design.md'),
|
||||
).toBe('unclassified');
|
||||
expect(categoryById.get('agent-result:design-foundation:final-run')).toBe(
|
||||
'document',
|
||||
);
|
||||
for (const category of categoryById.values()) {
|
||||
expect(PROJECT_RESOURCE_CANVAS_SECTIONS).toContain(category);
|
||||
}
|
||||
});
|
||||
|
||||
it('只登记游戏代码的项目:代码资产落在待归类栏目而不是被分区轴排除', () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'code-only-project',
|
||||
'只登记游戏代码的项目',
|
||||
);
|
||||
manifest.assets = [
|
||||
{
|
||||
id: 'game-entry',
|
||||
kind: 'code',
|
||||
mediaType: 'text/html',
|
||||
localPath: 'game/index.html',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
];
|
||||
const codeTask = manifest.tasks.find(
|
||||
(task) => task.id === 'code-prototype',
|
||||
);
|
||||
if (!codeTask) {
|
||||
throw new Error('缺少游戏代码测试任务');
|
||||
}
|
||||
codeTask.status = 'completed';
|
||||
codeTask.artifacts = ['game/main.js'];
|
||||
|
||||
const resources = projectResourcesFromReadModels(manifest, [], []);
|
||||
const categories = resources.map((resource) =>
|
||||
projectResourceCanvasCategory(resource),
|
||||
);
|
||||
|
||||
expect(resources.map((resource) => resource.id)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'asset:game-entry',
|
||||
'task:code-prototype:game/main.js',
|
||||
]),
|
||||
);
|
||||
// 旧四栏目口径把游戏代码排除在可见栏目之外,只登记代码的项目因此四栏全空。
|
||||
expect(new Set(categories)).toEqual(new Set(['unclassified']));
|
||||
expect(PROJECT_RESOURCE_CANVAS_SECTIONS).toContain('unclassified');
|
||||
});
|
||||
|
||||
it('资源类型判定只决定准入与显示类型,无法识别的二进制产物不进画布', () => {
|
||||
expect(projectedResourceKind({ path: 'game/main.js', mediaType: '' })).toBe(
|
||||
'code',
|
||||
);
|
||||
expect(
|
||||
projectedResourceKind({
|
||||
path: 'assets/ui.svg',
|
||||
mediaType: 'image/svg+xml',
|
||||
}),
|
||||
).toBe('art');
|
||||
expect(
|
||||
projectedResourceKind({ path: 'audio/theme.wav', mediaType: '' }),
|
||||
).toBe('audio');
|
||||
expect(
|
||||
projectedResourceKind({ path: 'memory/gdd.md', mediaType: '' }),
|
||||
).toBe('document');
|
||||
expect(
|
||||
projectedResourceKind({
|
||||
path: 'build/game.bundle',
|
||||
mediaType: 'application/octet-stream',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
projectedResourceKind({
|
||||
path: 'imports/archive.zip',
|
||||
mediaType: 'application/zip',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
LEGACY_PROJECT_RESOURCE_CANVAS_SECTIONS,
|
||||
PROJECT_RESOURCE_CANVAS_SECTIONS,
|
||||
type ProjectResourceCanvasCategory,
|
||||
} from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK,
|
||||
LEGACY_RESOURCE_CANVAS_SECTION_TARGETS,
|
||||
normalizeResourceCanvasPosition,
|
||||
resolveResourceCanvasSection,
|
||||
} from '../src/view/project-development/resourceCanvasSectionMapping';
|
||||
|
||||
function categoryByResourceId(category: ProjectResourceCanvasCategory) {
|
||||
return new Map<string, ProjectResourceCanvasCategory>([
|
||||
['resource-1', category],
|
||||
]);
|
||||
}
|
||||
|
||||
describe('资源画布分区轴与新旧栏目映射', () => {
|
||||
it('现行分区为 6 类资产分类加末尾的项目版本栏目', () => {
|
||||
expect(PROJECT_RESOURCE_CANVAS_SECTIONS).toEqual([
|
||||
'ui-interaction',
|
||||
'character',
|
||||
'scene',
|
||||
'audio',
|
||||
'document',
|
||||
'unclassified',
|
||||
'version',
|
||||
]);
|
||||
});
|
||||
|
||||
it('每个旧栏目值都有归并目标,且回落栏目本身属于目标集合', () => {
|
||||
for (const legacy of LEGACY_PROJECT_RESOURCE_CANVAS_SECTIONS) {
|
||||
const targets = LEGACY_RESOURCE_CANVAS_SECTION_TARGETS[legacy];
|
||||
expect(targets.length).toBeGreaterThan(0);
|
||||
for (const target of targets) {
|
||||
expect(PROJECT_RESOURCE_CANVAS_SECTIONS).toContain(target);
|
||||
}
|
||||
expect(targets).toContain(
|
||||
LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK[legacy],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('旧栏目坐标按资源当前分区归并,x / y / manuallyPlaced 原样保留', () => {
|
||||
const position = {
|
||||
resourceId: 'resource-1',
|
||||
section: 'art' as const,
|
||||
x: -24,
|
||||
y: 96,
|
||||
manuallyPlaced: true,
|
||||
};
|
||||
|
||||
for (const legacy of LEGACY_PROJECT_RESOURCE_CANVAS_SECTIONS) {
|
||||
for (const target of LEGACY_RESOURCE_CANVAS_SECTION_TARGETS[legacy]) {
|
||||
const normalized = normalizeResourceCanvasPosition(
|
||||
{ ...position, section: legacy },
|
||||
categoryByResourceId(target),
|
||||
);
|
||||
expect(normalized).toEqual({
|
||||
position: {
|
||||
resourceId: 'resource-1',
|
||||
section: target,
|
||||
x: -24,
|
||||
y: 96,
|
||||
manuallyPlaced: true,
|
||||
},
|
||||
changed: legacy !== target,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('旧 art 是扩展名口径,按资源当前分区落入四类资产栏目', () => {
|
||||
expect(LEGACY_RESOURCE_CANVAS_SECTION_TARGETS.art).toEqual([
|
||||
'ui-interaction',
|
||||
'character',
|
||||
'scene',
|
||||
'unclassified',
|
||||
]);
|
||||
for (const target of LEGACY_RESOURCE_CANVAS_SECTION_TARGETS.art) {
|
||||
expect(resolveResourceCanvasSection('art', target)).toBe(target);
|
||||
}
|
||||
expect(LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK.art).toBe('unclassified');
|
||||
});
|
||||
|
||||
it('旧 code 统一归入待归类', () => {
|
||||
expect(LEGACY_RESOURCE_CANVAS_SECTION_TARGETS.code).toEqual([
|
||||
'unclassified',
|
||||
]);
|
||||
expect(resolveResourceCanvasSection('code', 'unclassified')).toBe(
|
||||
'unclassified',
|
||||
);
|
||||
expect(resolveResourceCanvasSection('code', 'document')).toBeNull();
|
||||
});
|
||||
|
||||
it('新旧同名的旧值不产生额外写入,独有旧值改写后必然判定为变化', () => {
|
||||
for (const legacy of ['document', 'audio', 'version'] as const) {
|
||||
const normalized = normalizeResourceCanvasPosition(
|
||||
{
|
||||
resourceId: 'resource-1',
|
||||
section: legacy,
|
||||
x: 12,
|
||||
y: 34,
|
||||
manuallyPlaced: false,
|
||||
},
|
||||
categoryByResourceId(legacy),
|
||||
);
|
||||
expect(normalized?.position.section).toBe(legacy);
|
||||
expect(normalized?.changed).toBe(false);
|
||||
}
|
||||
|
||||
for (const legacy of ['art', 'code'] as const) {
|
||||
const target = LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK[legacy];
|
||||
const normalized = normalizeResourceCanvasPosition(
|
||||
{
|
||||
resourceId: 'resource-1',
|
||||
section: legacy,
|
||||
x: 12,
|
||||
y: 34,
|
||||
manuallyPlaced: false,
|
||||
},
|
||||
categoryByResourceId(target),
|
||||
);
|
||||
expect(normalized?.position.section).toBe(target);
|
||||
expect(normalized?.changed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('现役分区与资源分区不一致时沿用既有行为丢弃该坐标', () => {
|
||||
expect(resolveResourceCanvasSection('scene', 'character')).toBeNull();
|
||||
expect(resolveResourceCanvasSection('version', 'document')).toBeNull();
|
||||
expect(
|
||||
normalizeResourceCanvasPosition(
|
||||
{
|
||||
resourceId: 'resource-1',
|
||||
section: 'scene',
|
||||
x: 0,
|
||||
y: 0,
|
||||
manuallyPlaced: true,
|
||||
},
|
||||
categoryByResourceId('character'),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('不猜测无法识别的分区取值和未知资源', () => {
|
||||
expect(resolveResourceCanvasSection('not-a-section', 'scene')).toBeNull();
|
||||
expect(resolveResourceCanvasSection(undefined, 'scene')).toBeNull();
|
||||
expect(resolveResourceCanvasSection(null, 'scene')).toBeNull();
|
||||
expect(
|
||||
normalizeResourceCanvasPosition(
|
||||
{
|
||||
resourceId: 'missing-resource',
|
||||
section: 'scene',
|
||||
x: 0,
|
||||
y: 0,
|
||||
manuallyPlaced: true,
|
||||
},
|
||||
new Map<string, ProjectResourceCanvasCategory>(),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -44,10 +44,10 @@
|
||||
|
||||
- “按依赖”和“按类型”分别保存画布位置。
|
||||
- dependency / type 自动布局与资源卡手动拖动并存;空项目分区展览仍只提供分区空态。
|
||||
- 切换布局模式后恢复该模式 sidecar 中已有坐标;历史手动坐标只读恢复,不删除、不重置、不迁移。
|
||||
- 切换布局模式后恢复该模式 sidecar 中已有坐标;历史手动坐标只读恢复,不删除、不重置、不迁移。分区轴由旧四栏目切换为「6 类资产分类 + 项目版本栏目」后,旧 `art / code` 值按资源当前分类读时归并,坐标与 `manuallyPlaced` 原样保留。
|
||||
- 新资源首次进入某个布局时才执行默认不重叠排版;已有坐标不得被自动排序覆盖。
|
||||
- 依赖布局使用资源生成/引用关系;类型布局按资源大类、子类型、尺寸规格排序。
|
||||
- 资源分区由资源投影固定,前端交互不能改变分类。
|
||||
- 资源分区由资源投影固定:manifest 资产按 `category` 归入 6 类资产栏目,项目版本单独成栏,前端交互不能改变分类。
|
||||
- 非空资源页支持资源卡手动拖动:超过移动阈值后乐观更新坐标与依赖线,结束时提交一次手动布局 CAS;拖动后的点击不触发详情。空画布拖拽仍只平移 viewport。
|
||||
|
||||
#### 3.3.1 资源管理三个串行阶段
|
||||
@@ -321,7 +321,8 @@ type UpdateProjectResourceCanvasLayoutResult =
|
||||
- type 模式资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID。dependency 模式只永久保留 `manuallyPlaced=true` 的历史坐标;`manuallyPlaced=false` 属于可派生自动位置,在 Rust 关系图首次就绪、`dependencyDepth` 或资源拓扑身份签名(精确引用端点和聚合 task-flow 成员)变化后按最终拓扑确定性重算。签名以稳定资源 ID 的规范端点 / 成员序列生成固定大小摘要,不使用显示名称或浏览器测量值;自动重算不得移动手动坐标,协调结果与持久布局逐项一致时不得产生 CAS 写入。
|
||||
- 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。
|
||||
- 窗口尺寸变化只改变当前栏目的可视范围,不回写或裁切持久坐标,也不因资源 extent 或 resize 把已平移的 viewport 拉回内容边界。当前客户端继续以 `1280×800` 横屏合同验收。
|
||||
- 任一可见资源出现后,普通用户资源管理固定使用 `设计文档 -> 美术资源 -> 音乐音效 -> 项目版本` 四栏目分页画布;游戏代码仍保留在内部资源、布局和依赖事实中,但不进入普通资源画布的导航、分页、卡片、搜索或详情入口。每个可见栏目按 `projectId + mode + category` 保留独立 viewport,普通 wheel 平移当前视图,`Ctrl/Cmd + wheel` 以指针位置为锚点缩放当前无限画布,空白拖动只平移当前栏目;非空状态不提供分区高度、分区内部滚动或分区内容倍率。搜索和详情开关不得重置 viewport,项目、mode 或栏目切换只恢复各自会话状态,显式复位才重新适配当前栏目内容。
|
||||
- 任一资源出现后,普通用户资源管理固定使用 `UI 交互 -> 角色与对象 -> 场景与环境 -> 音频 -> 文档 -> 待归类 -> 项目版本` 七栏目分页画布。前六栏是 manifest 资产的功能分类 `category`(`ui-interaction / character / scene / audio / document / unclassified`),项目版本不是资源资产、6 类资产分类轴对它不适用,因此固定单独成栏;已投影资源都进入画布导航、分页、卡片、搜索与详情入口,不再有被分区轴排除的内部栏目。每个栏目按 `projectId + mode + category` 保留独立 viewport,普通 wheel 平移当前视图,`Ctrl/Cmd + wheel` 以指针位置为锚点缩放当前无限画布,空白拖动只平移当前栏目;非空状态不提供分区高度、分区内部滚动或分区内容倍率。搜索和详情开关不得重置 viewport,项目、mode 或栏目切换只恢复各自会话状态,显式复位才重新适配当前栏目内容。
|
||||
- 布局 sidecar 的 `section` 允许继续出现旧四 / 五栏目取值(`code / document / version / art / audio`),读取时按资源当前分区做一次读时归并:`document / audio / version` 与新分区同名,1:1 保留;旧 `art` 是「扩展名 + mediaType」口径,按资源当前分类落入 `ui-interaction / character / scene / unclassified`;旧 `code` 落入 `unclassified`;无法精确归并时回落到 `unclassified`。归并只改写 `section`,`x`、`y` 与 `manuallyPlaced` 原样保留,不写迁移脚本、不删除、不重置。
|
||||
- 首次载入项目中的既有资源不显示未读标识。当前会话内,非当前栏目出现稳定 ID 的新资源时,在对应栏目名称右上角显示红点;当前栏目新增资源不显示红点,用户通过点击或程序跳转进入该栏目后立即清除。未读状态只属于当前前端会话,并按 `projectPath + projectId` 隔离,切换项目时清空,不写入 manifest、布局 sidecar 或后端。
|
||||
- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;dependency 模式必须先等待与当前 `projectPath + projectId + resource inputs` 匹配的 Rust 图进入 `ready` 或 `failed` 终态,等待期间不得创建 fallback、读取 sidecar、协调资源或入队保存。`failed` 只允许以空图降级初始化一次。项目或 mode 已切换后返回的旧异步结果必须丢弃。
|
||||
- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。当前 scope 内资源自动协调写入使用单写者 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。
|
||||
@@ -354,7 +355,7 @@ type UpdateProjectResourceCanvasLayoutResult =
|
||||
|
||||
### 5.3 资源类型与替换兼容性(P1)
|
||||
|
||||
实现状态(2026-08-29):内部资源投影仍识别“设计文档、美术资源、音乐音效、游戏代码、项目版本”五类事实,但普通用户资源画布只展示“设计文档 -> 美术资源 -> 音乐音效 -> 项目版本”四个栏目;游戏代码不进入画布导航、分页、卡片、搜索或详情入口。设计文档接收受支持的 UTF-8 文档、代码资产中的文档类型和合法 Agent 文本回执;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本;美术资源接收图片、SVG、动画和视频类产物;音乐音效接收 manifest 资产、上传登记资产和已完成任务 `artifacts` 明确声明的音频产物;游戏代码继续接收 Direct Codex / 任务产物登记的 HTML、CSS 和 JavaScript,底层文件、manifest 事实、生成/编辑/运行能力、项目版本引用与依赖关系不变。无法识别的二进制任务产物和附件不进入资源画布。受控读取、中央聚焦、失败空态与媒体播放不改变 manifest 真相;编辑成功后只追加新的 asset 或版本子记录。
|
||||
实现状态(2026-09-10):资源画布分区口径是 manifest 资产的功能分类 `category`(`ui-interaction / character / scene / audio / document / unclassified`)加末尾独立的「项目版本」栏目,不再按扩展名或 mediaType 派生分区。`icon / icon-spritesheet / icon-spec / ui-design` 进入 UI 交互,`character / character-animation` 进入角色与对象,`scene` 进入场景与环境,`sound-effect / background-music / audio` 进入音频,`spec` 与合法 Agent 文本回执进入文档;`image / video / code / publication-material` 以及任务产物、导入附件进入待归类,只登记游戏代码的项目因此有可见栏目与卡片,不再出现四栏全空;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本。扩展名分类器只保留准入与卡片显示类型职责:无法识别的二进制任务产物和附件不进入资源画布。受控读取、中央聚焦、失败空态与媒体播放不改变 manifest 真相;编辑成功后只追加新的 asset 或版本子记录。既有布局 sidecar 的旧栏目坐标按读时归并继续生效,`x / y / manuallyPlaced` 原样保留。
|
||||
|
||||
资源身份固定使用 manifest asset ID、正式 version ID、Agent ID + run ID 或已导入资源稳定路径;显示标题、来源文案变化不得改变 `resourceId`,从而避免布局、依赖边、选择和聚焦状态因改名失效。
|
||||
|
||||
@@ -533,12 +534,12 @@ type ProjectAgentMudPointAttribution = {
|
||||
|
||||
### 7.5 资源栏目分页无限画布验收
|
||||
|
||||
1. 完全空项目继续显示四个可见栏目的分区展览;任一可见栏目出现资源后,dependency / type 都切换为固定四栏目分页画布,悬浮 Dock、底部下一页标题和普通 wheel 可访问全部可见栏目,空栏目也可打开空画布;游戏代码栏目和代码卡片不出现。
|
||||
2. 每个 `projectId + dependency|type + document|art|audio|version` 组合保留独立 viewport;隐藏代码资源的历史内部坐标和 sidecar 会话状态不被删除或重写,切换栏目、模式、项目和打开 / 关闭详情后恢复对应可见栏目平移与缩放,窗口 resize、媒体测量和资源 extent 变化不得重置用户 viewport。
|
||||
1. 完全空项目继续显示全部栏目的分区展览;任一栏目出现资源后,dependency / type 都切换为固定七栏目分页画布,悬浮 Dock、底部下一页标题和普通 wheel 可访问全部栏目,空栏目也可打开空画布;栏目为 `UI 交互 -> 角色与对象 -> 场景与环境 -> 音频 -> 文档 -> 待归类 -> 项目版本`,游戏代码资产落在「待归类」并正常显示卡片。
|
||||
2. 每个 `projectId + dependency|type + ui-interaction|character|scene|audio|document|unclassified|version` 组合保留独立 viewport;旧栏目(`art / code` 等)的历史坐标按读时归并进入对应新栏目,`x / y / manuallyPlaced` 原样保留、不被删除或重写,切换栏目、模式、项目和打开 / 关闭详情后恢复对应栏目平移与缩放,窗口 resize、媒体测量和资源 extent 变化不得重置用户 viewport。
|
||||
3. 当前栏目允许空白拖动无限平移;`Ctrl/Cmd + wheel` 以指针为锚点缩放,显式复位按包含负坐标资源在内的完整 bounds 适配内容。非空状态不显示分区高度、分区内部滚动或分区内容倍率操作。
|
||||
4. 资源卡超过 `5px` 阈值后进入拖动,预览和 dependency 线同步移动;成功释放只提交一次 `manuallyPlaced=true` CAS,取消、移出释放、媒体控制点击和未超过阈值均不写布局。
|
||||
5. dependency 引导线消费当前栏目的同类型精确引用,并与卡片共享同一 viewport transform;平移、缩放、拖动预览、搜索和 resize 后端点保持对齐,type 模式不渲染引导线。
|
||||
6. 栏目分页、viewport 和资源卡拖动只修改工作台会话状态或资源布局 sidecar,不改 manifest、项目 mutation revision、Runtime verification、Agent 权限和预览状态;图片、SVG、视频、音频、文档、任务产物、Agent 回执、项目版本卡片及非模态详情回归全部通过,游戏代码仅保留内部事实而不进入普通资源画布。
|
||||
6. 栏目分页、viewport 和资源卡拖动只修改工作台会话状态或资源布局 sidecar,不改 manifest、项目 mutation revision、Runtime verification、Agent 权限和预览状态;图片、SVG、视频、音频、文档、任务产物、Agent 回执、项目版本卡片及非模态详情回归全部通过,游戏代码资产落在「待归类」栏目并正常显示卡片。
|
||||
|
||||
### 7.6 阶段七完整验收
|
||||
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
- 关联文档:相关 PRD、技术文档、提交或 Issue
|
||||
```
|
||||
|
||||
## 2026-09-10 AGC 资源画布分区改为 6 类资产分类加项目版本栏目,旧栏目坐标读时归并
|
||||
|
||||
- 背景:资源画布原分区轴是「扩展名 + mediaType」派生的 `document / art / audio / code / version`,普通画布只显示其中四栏并隐藏游戏代码,与 `@` 面板、资源详情筛选已经收敛的单一权威 `category`(`ui-interaction / character / scene / audio / document / unclassified`)不一致。只登记游戏代码的项目会因资源签名非空进入分页画布,但 code 不在可见栏目里,于是四栏全空、一张卡都不显示。旧布局 sidecar 已存有旧 `section` 值,而 PRD 要求历史手动坐标只读恢复,不删除、不重置、不迁移。
|
||||
- 决策:分区轴改为 `PROJECT_RESOURCE_CANVAS_SECTIONS` = 6 类资产功能分类 + 末尾独立的「项目版本」栏目;项目版本不是资源资产,6 类资产分类轴对它不适用,固定单独成栏。扩展名分类器降级为只负责准入与卡片显示类型(`.zip` / `.exe` 等无法识别的二进制产物与附件仍不进入画布),不再决定分区。`game-creator-resource-layout.v1` 不升版;Rust `ProjectResourceCanvasSection` 保留旧 `code` / `art` 白名单值继续可反序列化,且不加未知值兜底,损坏 payload 仍失败关闭。读取时按资源当前分区归并:`document / audio / version` 同名 1:1 保留;旧 `art` 按资源当前分类落入 `ui-interaction / character / scene / unclassified`;旧 `code` 落入 `unclassified`;无法精确归并时回落 `unclassified`。归并只改写 `section`,`x / y / manuallyPlaced` 原样保留,不写迁移脚本;旧 `art` / `code` / `version` 平面合并后可能出现同坐标叠卡,按既有「不重置坐标」口径原样保留,不改写自动坐标。
|
||||
- 影响范围:`packages/shared/src/contracts/gameCreationApp.ts`、`server-rs/crates/shared-contracts/src/game_creation_app.rs`、`resourceProjectionModel.ts`、`resourceCanvasSectionMapping.ts`、`resourceCanvasLayoutModel.ts` 的栏目顺序与协调、`ProjectDevelopmentView` 的栏目标签 / 图标 / 可见栏目表。
|
||||
- 验证方式:`resourceCanvasSectionMapping.test.ts` 覆盖旧值 × 目标栏目矩阵(坐标与 `manuallyPlaced` 原样保留、`section` 改写、独有旧值改写后必然判定为变化)与回落栏目属于目标集合;`projectResourceProjectionModel.test.ts` 覆盖 6 类分区投影、项目版本独立成栏和只登记游戏代码的项目落在「待归类」;Rust 侧覆盖旧 section JSON 反序列化、未知值拒绝与既有旧 sidecar 文件读取。
|
||||
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
## 2026-09-05 Planning V2 一次性写路径对齐 V1 的项目锁等待窗口
|
||||
|
||||
- 背景:V2 审批修改意见后立即 `continue_planning_session_v2`。审批、回合启动、策略落盘和 hydrate 原先无等待取锁,和 GUI 重灌或其它写操作撞车就返回 `项目正在被其他写操作占用`,前端再映射成总控失败。V1 已用完整/短窗口处理同一形状。
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
## 2026-09-09 manifest 资源功能分类与自定义标签(UI 与写入)
|
||||
|
||||
资源筛选口径收敛为单一权威:`category`。`apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts` 删除按 mediaType 优先的 7 桶口径(`image / video / audio / ui / document / other` + `all`)与 `resourceReferenceFilterKind`,改为导出 `resourceReferenceCategory`、`resourceReferenceMatchesCategoryFilter`、`RESOURCE_REFERENCE_FILTERS`(`全部 / UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类`,对应 `all` + 6 个合法分类)与 `resourceReferenceCategoryLabel`。`ResourceReference` 新增 `category` 与 `tags`,由 `resourceReferenceFromAsset` 经 `gameCreationAppAssetCategory` / `gameCreationAppAssetTags` 从 manifest 资产投影;资源画布的 `ProjectResource` 同步投影 `assetCategory` / `assetTags`,非 manifest 资产(任务产物、附件、Agent 回执、项目版本)没有 `category` 事实源,统一按 `unclassified` 参与筛选,不按扩展名另造分类。语义变化:旧「图片」「视频」桶在 category 口径下不再独立存在,`image` / `video` / `code` / `publication-material` 归 `待归类`,`icon / icon-spritesheet / icon-spec / ui-design` 归 `UI 交互`,`character / character-animation` 归 `角色与对象`,`scene` 归 `场景与环境`。
|
||||
资源筛选口径收敛为单一权威:`category`。`apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts` 删除按 mediaType 优先的 7 桶口径(`image / video / audio / ui / document / other` + `all`)与 `resourceReferenceFilterKind`,改为导出 `resourceReferenceCategory`、`resourceReferenceMatchesCategoryFilter`、`RESOURCE_REFERENCE_FILTERS`(`全部 / UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类`,对应 `all` + 6 个合法分类)与 `resourceReferenceCategoryLabel`。`ResourceReference` 新增 `category` 与 `tags`,由 `resourceReferenceFromAsset` 经 `gameCreationAppAssetCategory` / `gameCreationAppAssetTags` 从 manifest 资产投影;资源画布的 `ProjectResource` 同步投影 `assetCategory` / `assetTags`,非 manifest 资产(任务产物、附件、Agent 回执、项目版本)没有 `category` 事实源,统一按 `unclassified` 参与筛选,不按扩展名另造分类。语义变化:旧「图片」「视频」桶在 category 口径下不再独立存在,`image` / `video` / `code` / `publication-material` 归 `待归类`,`icon / icon-spritesheet / icon-spec / ui-design` 归 `UI 交互`,`character / character-animation` 归 `角色与对象`,`scene` 归 `场景与环境`。资源画布分区使用同一口径:`PROJECT_RESOURCE_CANVAS_SECTIONS` = 6 类资产分类加末尾独立的「项目版本」栏目(项目版本不是资源资产,固定单独成栏),扩展名分类器降级为只负责准入与卡片显示类型,不再决定分区;布局 sidecar 里的旧栏目取值按资源当前分类读时归并,坐标原样保留。
|
||||
|
||||
分类与标签的唯一写入命令是 `update_local_project_resource_classification`(输入 `projectPath`、`expectedProjectId`、`expectedProjectRevision`、`assetId`、`category`、`tags`),实现落在 `project/manifest.rs` 的 `update_manifest_asset_classification_at`:走 `mutate_manifest_at` 既有 manifest 写锁与原子写入,命令包装层只做 `asset.register` 权限校验;持项目写锁后按 `expectedProjectId` 与当前项目 revision 做 CAS,不匹配分别返回 `project-identity-conflict` 与 `project-revision-conflict`。`category` 必须是 6 个合法值之一,非法值报错失败且不回退到 `kind` 派生;`tags` 走 `normalize_game_creation_app_asset_tags`(trim、去空、去重)。写入只改目标条目的 `category` / `tags`,保持 `GameCreationAppAssetManifestEntry` 字段构成与顺序不变,成功后项目 revision 前进一次。UI 上 `@` 面板与资源画布共用 `packages/shared` 的 `PlatformSegmentedTabs` 渲染同一组筛选标签,画布筛选状态与画布视口一样按「按依赖 / 按类型」分别保存;资源详情面板提供「分类与标签」独立面板(`ThemedModal` + `PlatformSegmentedTabs` + `PlatformTextField`)做最小编辑,保存后重读 manifest 并经 `onManifestChange` 反映到筛选与投影。
|
||||
|
||||
@@ -591,7 +591,7 @@ game-project/
|
||||
|
||||
- 页面骨架固定为左侧现有全局导航、中间主视窗、右侧陶泥儿对话和底部子 Agent 状态栏;不新建第二套客户端或平行项目页。
|
||||
- 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。
|
||||
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
|
||||
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按 manifest 资产功能分类 `category`(`UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类`)加末尾独立的「项目版本」栏目分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
|
||||
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供工具栏、工具侧边栏或可拖动标题栏。2026-08-10 起聚焦态以资源元数据、Rust 权威深度、同类型上下游 / 任务流和版本信息为首屏;美术图片与视频只保留卡内本体,不在详情重复放大。安全文档正文与按意图读取的音频控制位于元数据之后;美术编辑、音频编辑 / 替换、版本替换或运行模块仍不在本阶段。
|
||||
- `@genarrative/image-canvas-react` 的 `CanvasWorld` 接收逻辑 `viewport`,`viewport.scale` 表示最终视觉比例;渲染细节由共享包封装。调用方测试将预期逻辑 `{ x, y, scale }` 传入公开 helper `canvasViewportToWorldTransform`,再比较实际 world 的 `style.transform`;不自行换算渲染值,不以缩放按钮文案替代 viewport 断言。world 继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不新增矩形尺寸 API;资源页的 `navigationBounds` 只用于布局、fit、关系线 geometry 和数据属性,不能再设置 world DOM 宽高。非缩放描边只应用于资源依赖关系线等几何 overlay,Lucide 等静态卡片图标保持自身正常缩放,避免被统一 SVG 规则压窄;普通位图不强制 `pixelated` 插值。依赖线、卡片拖拽、平移和滚轮锚点继续使用同一逻辑 viewport 坐标。
|
||||
|
||||
@@ -612,7 +612,7 @@ game-project/
|
||||
- 固定四区分别在自己的 `.game-resource-plane` 内挂载 `ResourceDependencyOverlay`。每个分区最多创建一个 `ResizeObserver`,并把本区 scroll / resize / observer 回调合并到本区单一在途 `requestAnimationFrame`;SVG 和卡片直接消费同一逻辑坐标与父 plane scale,不通过异步屏幕测量重绑端点。分区 viewport 原生裁剪本区 SVG;一端离屏时只保留对应方向、带同语义 marker 的边界继续线,两端离屏时隐藏。项目 / mode 切换、搜索隐藏、资源删除和卸载继续丢弃旧 viewport / path 并清理 observer 与监听。
|
||||
- 阶段三的聚类已落在 `reconcileResourceCanvasLayout` 的 dependency 自动坐标派生步骤。它先按资源分类过滤 reference edge,并把 task-flow 按固定分类切成仅在同类 source / target 同时存在时有效的聚合超边;每个 section 再用精确边与 flow 临时节点建无向邻接表,以迭代遍历生成弱连通组。task-flow 只以“流节点 -> 成员”的线性成员关联参与布局,绝不展开 source × target 资源组合,不绘制 SVG,也不把搜索后的 visible set 用作输入。`dependencyDepth` 仍唯一决定横向业务层级;相关簇按 `minDependencyDepth + minStableResourceId` 排序,孤立集合置于所有相关簇之后,簇间使用单一布局常量留白。每个相关簇内先按稳定资源 ID 建同层初始序,再做固定两轮左至右 / 右至左的中位数扫描:精确引用读取相邻层的上下游 rank,task-flow 读取另一端成员 rank 的中位数,平局按稳定资源 ID 收口。dependency 自动位置使用 `48px` 列间走线区和 `40px` 行间走线区;每个相关簇以最大层行数确定高度,资源较少的层增加确定性半差偏移而在簇内居中,菱形 / 分叉两侧因此保持均衡。type 模式仍使用原 `16px` 行列间距。跨分类 read model 关系不进入前端聚类、边界偏置或拓扑签名,但 Rust 深度与原始图真相不改。显示坐标必须遵守前后端共享的 `0..=1_000_000` 上限;超深依赖在最后合法列确定性饱和,保留原始 `dependencyDepth`,同列资源继续按稳定顺序纵向避让。若任一自动 `x / y` 无法在合法域内落槽,协调必须在 IPC 前失败关闭,不持续提交必然被 Rust 拒绝的坐标。算法保持 `O(V + E)` 图遍历,加固定轮数的层内稳定排序和现有有界占用索引;4096 资源不允许全量配对。旧的 `manuallyPlaced=true` 坐标先占位并原样保留,聚类只派生自动坐标;同类型拓扑身份签名只记录有界的资源 ID 端点 / 成员,以便深度未变但邻接变化时触发重派生。图边、cluster ID 和签名都不写 sidecar。
|
||||
- 中间主视窗提供 `resource-overview / resource-editor / ui-editor / run` 四种状态。2026-08-10 起普通用户“新增资源”显示为禁用态且处理函数拒绝 create;所有现役资源从聚焦态“编辑资源”进入非破坏性派生:静态图片走 `derive + editKind='image-reference'`,SVG、视频、音频、文档/代码、Agent 回执和项目版本进入统一资源编辑壳并按能力分流。编辑面板只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。
|
||||
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执、已导入附件和已完成任务明确登记的产物派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,未完成任务或未在 `artifacts` 中登记的任意本地音频也不冒充正式资源。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
|
||||
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执、已导入附件和已完成任务明确登记的产物派生资源,固定按 manifest 资产功能分类 `category`(`UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类`)加末尾独立的「项目版本」栏目分区;未知任务产物不再兜底为版本,未完成任务或未在 `artifacts` 中登记的任意本地音频也不冒充正式资源。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
|
||||
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源总览卡片拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供通用工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态。2026-08-30 视觉验收修正:资源总览所有栏目初次适配与复位最多以 `1.5` 倍缩放卡片,避免单个低尺寸卡片被插值放大成糊图;用户主动缩放仍沿用通用画布倍率,并按“排序模式 + 栏目”保留当前会话内的平移和缩放。美术资源聚焦态改为视口级大预览,保留原始资源读取与元数据,不生成第二份缩略图,图片 / 视频预览按弹窗可用高度展示并允许正文滚动。该资源总览边界不限制后续素材创作无限画布内的图片图层移动/缩放、生成和正式回写。
|
||||
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并保留素材信息和数值微调面板;两个面板保持原有 `156px` 最小高度,没有真实数据时只让正文为空,不渲染预设字段、默认数值、未载入控件或自然语言功能占位,也不随空内容收缩。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `</body>` / `</html>`。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;宿主单独记录最近一次合法上报的 iframe viewport,首次收到由自身 fit 切换产生的新 viewport 测量时只确认该 viewport、不反向改写内容尺寸,待 viewport 稳定后仍接受真实内容宽高变化,从而阻断 `100vh` / 百分比布局在两个适配尺寸之间回灌振荡。重复内容尺寸不更新 React 状态,陈旧 viewport 消息继续忽略。容器 resize 期间保留内容尺寸与已观察 viewport,只按新容器尺寸连续重算缩放,避免拖动窗口时在原生尺寸和 fit 之间闪烁;preview URL 变化时才清空状态并重新测量。放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。
|
||||
- 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
## 目标
|
||||
|
||||
资源管理在空项目中展示现有按类型式分区展览。任一栏目出现资源后,“按依赖”和“按类型”共用同一套栏目分页画布:固定五个栏目各自拥有一个铺满资源管理区域的独立画布,空栏目也保留可打开的空画布;栏目大纲悬浮在画布左侧中间,只显示图标和栏目名称,不占用画布布局宽度;顶部显示当前栏目标题,底部显示下一页标题。普通滚轮平移当前视图,按住 Ctrl/Meta 的滚轮以指针为锚点缩放当前画布。两种模式唯一差异是“按依赖”在当前栏目内绘制引导线。资源详情是叠加在资源画板之上的非模态卡片;打开、切换和关闭详情都不得卸载背景工具栏、资源卡、依赖连线或重置 viewport、搜索和排序模式。
|
||||
资源管理在空项目中展示现有按类型式分区展览。任一栏目出现资源后,“按依赖”和“按类型”共用同一套栏目分页画布:固定七个栏目各自拥有一个铺满资源管理区域的独立画布,空栏目也保留可打开的空画布;栏目大纲悬浮在画布左侧中间,只显示图标和栏目名称,不占用画布布局宽度;顶部显示当前栏目标题,底部显示下一页标题。普通滚轮平移当前视图,按住 Ctrl/Meta 的滚轮以指针为锚点缩放当前画布。两种模式唯一差异是“按依赖”在当前栏目内绘制引导线。资源详情是叠加在资源画板之上的非模态卡片;打开、切换和关闭详情都不得卸载背景工具栏、资源卡、依赖连线或重置 viewport、搜索和排序模式。
|
||||
|
||||
图片资源从“编辑资源”进入资源派生编辑(`derive + editKind='image-reference'`),结果固定创建新的本地 asset,源资源与源文件保持不变;具体来源核验、请求 DTO 与幂等合同见《【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09》。
|
||||
|
||||
@@ -44,12 +44,13 @@
|
||||
- 资源依赖图的精确引用身份同时支持两种稳定索引:远端 `source.resourceId` 与本地 `local-asset:<manifest assetId>`。派生资源把 `referenceResourceIds` 写成本地 canonical 身份时,必须能解析到同一项目的唯一资源;候选去重后才允许建立一条 `asset-reference`,无法唯一解析的引用继续进入 `unresolvedReferenceResourceIds`,不得在前端猜测连线。
|
||||
- AGC 侧共享契约维护 canonical 资产类型目录和历史读时映射:`game-background -> scene`、`character-art -> character`、`ui-prototype -> ui-design`、`art-spritesheet -> icon-spritesheet`、`art-spritesheet-slice -> icon`、`illustration/game-art -> image`。该目录只约束 AGC 新写入与投影,不要求现役 Web 美术画布同步改造;历史 manifest 保持原值,读取时可归一到 canonical 展示。
|
||||
- 角色动画派生结果的 manifest `kind` 固定写 `character-animation`,`source.generationKind` 同步保留 `character-animation`,媒体仍以预览视频加正式序列帧登记。图片编辑等普通派生继续继承源资源语义 kind;后续如需改写为 canonical kind,只能在读取投影或显式迁移中完成,不重写历史 manifest。
|
||||
- 固定栏目页始终为 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本`。若某栏目没有对应投影,Dock 仍显示该栏目并允许打开空画布;空画布只表达“当前没有已登记资源”,不得由 UI 补假数据。
|
||||
- 固定栏目页始终为 `UI 交互 -> 角色与对象 -> 场景与环境 -> 音频 -> 文档 -> 待归类 -> 项目版本`。分区口径是 manifest 资产的功能分类 `category`(`ui-interaction / character / scene / audio / document / unclassified`);项目版本不是资源资产、6 类资产分类轴对它不适用,固定单独成栏排在末尾;没有 `category` 事实源的任务产物与导入附件归 `待归类`,合法 Agent 文本回执归 `文档`。若某栏目没有对应投影,Dock 仍显示该栏目并允许打开空画布;空画布只表达“当前没有已登记资源”,不得由 UI 补假数据。
|
||||
- 布局 sidecar 的 `section` 允许继续出现旧四 / 五栏目取值(`code / document / version / art / audio`),读取时按资源当前分区归并:`document / audio / version` 同名 1:1 保留,旧 `art` 按资源当前分类落入 `ui-interaction / character / scene / unclassified`,旧 `code` 落入 `unclassified`,无法精确归并时回落 `unclassified`。归并只改写 `section`,`x / y / manuallyPlaced` 原样保留,不写迁移脚本、不删除、不重置。`game-creator-resource-layout.v1` schema 不升版,否则既有 sidecar 会被直接拒读。
|
||||
- Direct Codex 的游戏生成链路以 `game/index.html`、`game/style.css`、`game/game.js` 作为代码资产登记;是否额外产出设计文档由当前任务和 Agent 决定,资源页只消费已登记结果。
|
||||
|
||||
## 操作边界
|
||||
|
||||
- 栏目顺序固定为 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本`。完全空项目显示全部栏目的分区展览;任一栏目出现资源后,分页大纲以左侧垂直居中的悬浮 Dock 展示全部栏目。常态缩小、降低不透明度并移除容器与选中项背景,只露出栏目文字;悬停或键盘聚焦时平滑恢复完整尺寸,显示栏目图标、Dock 背景和选中态视觉强调。默认停留在该顺序中的第一个非空栏目,空栏目仍可打开空画布。
|
||||
- 栏目顺序固定为 `UI 交互 -> 角色与对象 -> 场景与环境 -> 音频 -> 文档 -> 待归类 -> 项目版本`。完全空项目显示全部栏目的分区展览;任一栏目出现资源后,分页大纲以左侧垂直居中的悬浮 Dock 展示全部栏目。常态缩小、降低不透明度并移除容器与选中项背景,只露出栏目文字;悬停或键盘聚焦时平滑恢复完整尺寸,显示栏目图标、Dock 背景和选中态视觉强调。默认停留在该顺序中的第一个非空栏目,空栏目仍可打开空画布。游戏代码资产按其 manifest `category` 落入 `待归类` 并正常显示卡片。
|
||||
- 滚轮不切换栏目。点击大纲、底部“下一页”标题、总览入口或自动定位资源属于显式切页;切页前必须终止旧栏目的画布拖动和 pointer capture,避免旧 viewport 写入新栏目。
|
||||
- 每种排序模式下的每个栏目画布都保留独立 viewport;首次进入该“排序模式 + 栏目”组合时按当前内容适配视口,离开后再返回则恢复该组合上次的平移和缩放。空白处拖拽平移画布,资源卡拖拽移动卡片并更新依赖线,Ctrl/Meta 缩放只作用于当前组合,不能牵动其它排序模式或栏目。
|
||||
- 依赖画布复用 `@genarrative/image-canvas-core` 的 viewport 计算,并复用现有资源卡片、布局和依赖连线模型。
|
||||
@@ -59,7 +60,7 @@
|
||||
- 资源卡拖动使用 `5px` 阈值区分点击与移动;移动期间按当前 scale 乐观换算世界坐标、显示拖动态并同步依赖线,释放时提交一次 `manuallyPlaced=true` 布局 CAS,取消则回滚预览且不提交。拖动后的释放点击不打开详情。
|
||||
- 依赖模式只在当前栏目画布内显示两端都属于该栏目的合法精确引用;装饰 SVG 与视觉隐藏的关系说明消费同一组可见边,搜索隐藏任一端点时两者同步移除。依赖线的 viewport 测量按动画帧合并,平移和缩放只更新已挂载观察器消费的最新 viewport,不得在每次输入时重建 ResizeObserver、scroll 或 resize 监听。任务流仍只参与同类型布局聚类,不绘线也不进入关系说明。
|
||||
- 资源详情卡包含元数据、媒体预览和“编辑资源”操作,但不使用全屏 backdrop、不声明 `aria-modal=true`、不把 `focusedResource` 作为背景工具栏渲染条件。角色资源同时显示“生成动画”时,两个业务操作按钮必须使用一致样式,不能依赖 DOM 中的首按钮位置。桌面端允许继续操作背景画板;窄屏可以使用有边界的贴边卡,但背景组件必须保持挂载。
|
||||
- 完全空项目继续使用可纵向滚动的五分区展览,不挂载分页画布或依赖画布的 `overflow: hidden` 交互壳;低高度窗口在两种排序模式下都能滚动到“项目版本”。
|
||||
- 完全空项目继续使用可纵向滚动的分区展览,不挂载分页画布或依赖画布的 `overflow: hidden` 交互壳;低高度窗口在两种排序模式下都能滚动到“项目版本”。
|
||||
- 在“按依赖 / 按类型”之间切换或离开资源管理进入运行视图后返回时,恢复对应“排序模式 + 栏目”的现有 viewport,不自动触发“复位资源画布”,也不得用另一排序模式的 viewport 覆盖用户已经完成的平移和缩放。首次 fit 与用户显式复位仍使用同一套真实资源包围盒算法。
|
||||
- 资源详情卡定位相对 `.game-workbench-stage`,桌面端在中间主视窗居中并受主视窗宽高边界约束;禁止使用相对整个窗口的右上角 `position: fixed` 定位。
|
||||
- 图片浮动工具条与图片精修草稿画布均已退役。图片的“编辑资源”入口走资源派生编辑,在资源编辑壳内提交,不再打开独立画布或图片下方快速编辑卡。
|
||||
@@ -94,12 +95,12 @@
|
||||
- 第一版图片展示边界为最大宽 `220px`、最大高 `180px`、最小短边 `96px`,展示框纵横比夹取在 `1:2..2:1`;图片始终 `object-fit: contain`,不得拉伸或裁剪。
|
||||
- `resourceCanvasLayoutModel` 的碰撞、自动布局、section/world extent、适配视口和拖动几何必须消费每个资源自己的 `width/height`,不能只改 CSS。
|
||||
- `ResourceDependencyOverlay` 的端点和绕障矩形必须消费同一资源矩形表,保证横图、竖图和 fallback 卡片的连线均对齐。
|
||||
- `code/document/audio/version` 继续使用固定 `180x128`;尺寸只属于展示投影,不写入 manifest,也不成为第二份资源业务真相。
|
||||
- `ui-interaction/character/scene/audio/document/unclassified/version` 继续使用固定 `180x128`;尺寸只属于展示投影,不写入 manifest,也不成为第二份资源业务真相。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 完全空项目在按依赖与按类型下都显示相同的分区展览;任一栏目出现资源后,两种模式都切换为栏目分页画布,大纲以左侧垂直居中的悬浮 Dock 覆盖在全宽画布上,包含全部栏目的图标和文字、不显示数量,空栏目仍可打开空画布。
|
||||
2. 栏目顺序为 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本`,游戏代码不再固定在首位;点击大纲、总览入口和底部下一页标题切页,滚轮不切换栏目。
|
||||
2. 栏目顺序为 `UI 交互 -> 角色与对象 -> 场景与环境 -> 音频 -> 文档 -> 待归类 -> 项目版本`;点击大纲、总览入口和底部下一页标题切页,滚轮不切换栏目。只登记游戏代码的项目在「待归类」栏目正常显示代码卡片。
|
||||
3. 在当前栏目内拖拽空白可以无限平移画布,拖拽资源卡可以移动卡片并同步依赖线,Ctrl/Meta + 滚轮以指针位置为锚点缩放,复位按钮可以适配当前栏目内容;普通平移不会因资源 extent、图片测量或窗口 resize 被拉回,连续缩放仍停在共享画布的最小/最大比例范围内。
|
||||
4. 按依赖模式显示当前栏目内同类型两端资源的合法精确引用,并具有与当前可见连线一致的无障碍关系说明;按类型模式不显示引导线;搜索隐藏任一端点后连线和说明同时消失。
|
||||
5. 点击任意资源后“按依赖 / 按类型 / 复位”等画布级动作仍保持挂载和原状态;顶部不出现手动新建入口。详情为非模态独立卡片,背景画板 viewport、搜索、排序、卡片和连线不卸载、不重置。
|
||||
|
||||
@@ -644,6 +644,66 @@ export function isSafeProjectResourceCanvasCoordinate(
|
||||
|
||||
export type ProjectResourceCanvasLayoutMode = 'dependency' | 'type';
|
||||
|
||||
/**
|
||||
* 资源画布分区轴:6 类资源资产功能分类 + 末尾独立的「项目版本」栏目。
|
||||
*
|
||||
* 项目版本不是资源资产,6 类资产分类轴对它不适用,因此固定在末尾单独成栏,
|
||||
* 不与「待归类」混在一起。
|
||||
*/
|
||||
export const PROJECT_RESOURCE_CANVAS_SECTIONS = [
|
||||
...GAME_CREATION_APP_ASSET_CATEGORIES,
|
||||
'version',
|
||||
] as const;
|
||||
|
||||
export type ProjectResourceCanvasCategory =
|
||||
(typeof PROJECT_RESOURCE_CANVAS_SECTIONS)[number];
|
||||
|
||||
/**
|
||||
* 旧四 / 五栏目分区值(扩展名 + mediaType 口径)的读兼容白名单。
|
||||
*
|
||||
* `document` / `audio` / `version` 在新旧口径下同名,只有 `art` / `code` 是旧口径独有值。
|
||||
* 这五个值是布局 sidecar 的历史取值,只用于继续读取既有坐标,不作为写入值。
|
||||
*/
|
||||
export const LEGACY_PROJECT_RESOURCE_CANVAS_SECTIONS = [
|
||||
'code',
|
||||
'document',
|
||||
'version',
|
||||
'art',
|
||||
'audio',
|
||||
] as const;
|
||||
|
||||
export type LegacyProjectResourceCanvasSection =
|
||||
(typeof LEGACY_PROJECT_RESOURCE_CANVAS_SECTIONS)[number];
|
||||
|
||||
/** 布局 sidecar 的 `section` 字段在磁盘上可能出现的全部取值:现行分区 + 旧栏目白名单。 */
|
||||
export type PersistedProjectResourceCanvasSection =
|
||||
| ProjectResourceCanvasCategory
|
||||
| LegacyProjectResourceCanvasSection;
|
||||
|
||||
export function isProjectResourceCanvasCategory(
|
||||
value: unknown,
|
||||
): value is ProjectResourceCanvasCategory {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
(PROJECT_RESOURCE_CANVAS_SECTIONS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function isLegacyProjectResourceCanvasSection(
|
||||
value: unknown,
|
||||
): value is LegacyProjectResourceCanvasSection {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
(LEGACY_PROJECT_RESOURCE_CANVAS_SECTIONS as readonly string[]).includes(
|
||||
value,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧栏目口径(扩展名 + mediaType)的分区值。当前仍被现役资源画布消费;
|
||||
* 分区轴切换后由 `ProjectResourceCanvasCategory` 取代。
|
||||
*/
|
||||
export type ProjectResourceCanvasSection =
|
||||
| 'code'
|
||||
| 'document'
|
||||
|
||||
@@ -707,16 +707,37 @@ pub enum ProjectResourceCanvasLayoutMode {
|
||||
Type,
|
||||
}
|
||||
|
||||
/// 资源画布分区轴:6 类资源资产功能分类 + 末尾独立的「项目版本」栏目。
|
||||
///
|
||||
/// 项目版本不是资源资产,6 类资产分类轴对它不适用,因此固定在末尾单独成栏。
|
||||
/// `Code` / `Art` 是旧四 / 五栏目(扩展名 + mediaType 口径)独有的历史取值,只用于继续
|
||||
/// 读取既有布局 sidecar;`Document` / `Audio` / `Version` 在新旧口径下同名,不需要别名。
|
||||
/// 写入侧只写现行 7 个分区之一。
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ProjectResourceCanvasSection {
|
||||
Code,
|
||||
Document,
|
||||
Version,
|
||||
Art,
|
||||
UiInteraction,
|
||||
Character,
|
||||
Scene,
|
||||
Audio,
|
||||
Document,
|
||||
Unclassified,
|
||||
Version,
|
||||
Code,
|
||||
Art,
|
||||
}
|
||||
|
||||
/// 现行分区顺序,必须与前端 `PROJECT_RESOURCE_CANVAS_SECTIONS` 保持一致。
|
||||
pub const PROJECT_RESOURCE_CANVAS_SECTIONS: [ProjectResourceCanvasSection; 7] = [
|
||||
ProjectResourceCanvasSection::UiInteraction,
|
||||
ProjectResourceCanvasSection::Character,
|
||||
ProjectResourceCanvasSection::Scene,
|
||||
ProjectResourceCanvasSection::Audio,
|
||||
ProjectResourceCanvasSection::Document,
|
||||
ProjectResourceCanvasSection::Unclassified,
|
||||
ProjectResourceCanvasSection::Version,
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectResourceCanvasPosition {
|
||||
@@ -2281,7 +2302,7 @@ mod tests {
|
||||
revision: 1,
|
||||
positions: vec![ProjectResourceCanvasPosition {
|
||||
resource_id: "asset-1".to_string(),
|
||||
section: ProjectResourceCanvasSection::Art,
|
||||
section: ProjectResourceCanvasSection::Scene,
|
||||
x: 10,
|
||||
y: 20,
|
||||
manually_placed: true,
|
||||
@@ -2299,7 +2320,7 @@ mod tests {
|
||||
"positions": [
|
||||
{
|
||||
"resourceId": "asset-1",
|
||||
"section": "art",
|
||||
"section": "scene",
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
"manuallyPlaced": true
|
||||
@@ -2310,6 +2331,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_canvas_partition_axis_keeps_asset_categories_then_version() {
|
||||
assert_eq!(
|
||||
PROJECT_RESOURCE_CANVAS_SECTIONS
|
||||
.map(|section| serde_json::to_value(section).expect("section should serialize")),
|
||||
[
|
||||
json!("ui-interaction"),
|
||||
json!("character"),
|
||||
json!("scene"),
|
||||
json!("audio"),
|
||||
json!("document"),
|
||||
json!("unclassified"),
|
||||
json!("version"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_canvas_layout_reads_legacy_section_values() {
|
||||
for (stored, expected) in [
|
||||
("code", ProjectResourceCanvasSection::Code),
|
||||
("art", ProjectResourceCanvasSection::Art),
|
||||
("document", ProjectResourceCanvasSection::Document),
|
||||
("audio", ProjectResourceCanvasSection::Audio),
|
||||
("version", ProjectResourceCanvasSection::Version),
|
||||
] {
|
||||
let payload = json!({
|
||||
"schemaVersion": GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
|
||||
"projectId": "project-layout-legacy",
|
||||
"mode": "type",
|
||||
"revision": 4,
|
||||
"positions": [{
|
||||
"resourceId": "asset-legacy",
|
||||
"section": stored,
|
||||
"x": -24,
|
||||
"y": 96,
|
||||
"manuallyPlaced": true
|
||||
}],
|
||||
"updatedAt": 9
|
||||
});
|
||||
let layout: ProjectResourceCanvasLayout =
|
||||
serde_json::from_value(payload).expect("legacy section must stay readable");
|
||||
assert_eq!(layout.positions[0].section, expected);
|
||||
assert_eq!(layout.positions[0].x, -24);
|
||||
assert!(layout.positions[0].manually_placed);
|
||||
assert_eq!(
|
||||
serde_json::to_value(layout.positions[0].section)
|
||||
.expect("legacy section should serialize"),
|
||||
json!(stored)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_canvas_layout_rejects_unknown_section_values() {
|
||||
let payload = json!({
|
||||
"schemaVersion": GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
|
||||
"projectId": "project-layout-unknown",
|
||||
"mode": "type",
|
||||
"revision": 1,
|
||||
"positions": [{
|
||||
"resourceId": "asset-unknown",
|
||||
"section": "not-a-section",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"manuallyPlaced": false
|
||||
}],
|
||||
"updatedAt": 1
|
||||
});
|
||||
assert!(serde_json::from_value::<ProjectResourceCanvasLayout>(payload).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_canvas_layout_revision_rejects_json_outside_js_safe_integer_range() {
|
||||
let layout = ProjectResourceCanvasLayout {
|
||||
|
||||
Reference in New Issue
Block a user