From 6264fed6b4a0aac32b9c3d46c803c0c000b316b9 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 20:50:26 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E7=94=BB=E9=9D=A2=E7=82=B9?= =?UTF-8?q?=E9=80=89=E7=BB=93=E6=9E=9C=E6=94=B6=E6=95=9B=E5=88=B0=20manife?= =?UTF-8?q?st=20=E7=8E=B0=E6=9C=89=E7=B4=A0=E6=9D=90=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增匹配纯函数:显式 id、精确路径后缀、文件名、序列帧地址四档,未知值一律丢弃 - 恰好命中一个素材且标签由文件名派生时,芯片名归一化为资源显示名 - 点选处理改为调用该纯函数,派发载荷字段与顺序不变 - 新增匹配与标签归一化的单元用例 --- .../runtimeInspectResourceMatch.ts | 173 +++++++++++ .../src/view/project-development/index.tsx | 31 +- .../tests/runtimeInspectResourceMatch.test.ts | 278 ++++++++++++++++++ 3 files changed, 468 insertions(+), 14 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/runtimeInspectResourceMatch.ts create mode 100644 apps/ai-game-creator-shell/tests/runtimeInspectResourceMatch.test.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/runtimeInspectResourceMatch.ts b/apps/ai-game-creator-shell/src/features/project-workspace/runtimeInspectResourceMatch.ts new file mode 100644 index 000000000..743b54b14 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/runtimeInspectResourceMatch.ts @@ -0,0 +1,173 @@ +import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { resourceDisplayName } from './resourceReferences'; + +/** + * 运行画面点选结果 → manifest 现有素材 id 的唯一匹配口径。 + * + * 桥(引擎档与 DOM 档)只给得出「显式素材 id」与「素材路径 / 贴图 URL」两种线索,两者都不可信: + * id 可能指向已删除 / 已改名的素材,路径可能是任意 URL pathname。宿主是唯一见过 manifest 的一方, + * 所以由这里把线索收敛成 manifest 里确实存在的 id —— **未知值一律丢弃**:Rust 侧遇到未知 id 会拒绝 + * 整条 `runtime-region` 引用,宁可少给不可给错(见 `docs/【功能说明】AGC聊天素材引用-2026-09-08.md` + * 「运行画面素材点选(2026-10-04)」第 4 条)。 + * + * 匹配优先级(结果按档位拼接后去重,档内按 manifest 顺序 / 输入顺序): + * + * 1. 显式 `resourceIds` 中确实登记在 manifest 的(按输入顺序); + * 2. `sourcePath` 与 `localPath` / `imageSequenceFrames[].imageSrc` 的**精确后缀匹配**: + * 按 `/`、`\` 分段逐段比较(大小写不敏感),`game/public/assets/hero.png` 命中 + * `assets/hero.png`,但段边界不同(`b/hero.png` vs `a/hero.png`)不算命中; + * 3. 文件名匹配(最后一段相等,大小写不敏感)——现有口径;重名素材命中多条时**全部返回**; + * 4. 序列帧 `imageSequenceFrames[].imageSrc` 与 `localPath` 同权参与第 2、3 档。 + */ +export type RuntimeInspectMatchInput = { + assets: readonly GameCreationAppAssetManifestEntry[]; + /** 桥给的素材路径 / 贴图 URL pathname(已过预览协议的净化,可能缺失)。 */ + sourcePath?: string | null; + /** 桥给的显式素材 id(已过预览协议的净化,可能缺失 / 重复 / 指向未知素材)。 */ + resourceIds?: readonly string[]; +}; + +export type RuntimeInspectMatchResult = { + resourceIds: string[]; +}; + +/** 路径分段:`/` 与 `\` 都算分隔符,丢弃空段(`a//b`、尾斜杠不会造出空段)。 */ +function pathSegments(value: string): string[] { + return value.split(/[\\/]+/u).filter((segment) => segment.length > 0); +} + +/** + * 文件名去扩展名:与桥的派生逐字同一套规则(最后一个 `.` 之前为非空词干才去, + * `.env` 这类只有扩展名的词干为空)。用于「进来的 label 是不是文件名派生的」这条判据。 + */ +function fileNameStem(fileName: string): string { + const dot = fileName.lastIndexOf('.'); + if (dot < 0) { + return fileName; + } + return dot === 0 ? '' : fileName.slice(0, dot); +} + +/** `sourcePath` 的文件名词干;没有文件名(空串 / 只有分隔符)时返回空串。 */ +function sourceFileNameStem(sourcePath: string | null | undefined): string { + if (!sourcePath) { + return ''; + } + const segments = pathSegments(sourcePath); + const fileName = segments[segments.length - 1]; + return fileName === undefined ? '' : fileNameStem(fileName); +} + +/** `path` 的分段是否以 `suffix` 结尾(逐段大小写不敏感,段边界即后缀边界)。 */ +function endsWithSegments(path: string[], suffix: string[]): boolean { + if (suffix.length === 0 || suffix.length > path.length) { + return false; + } + const offset = path.length - suffix.length; + return suffix.every( + (segment, index) => + path[offset + index]?.toLowerCase() === segment.toLowerCase(), + ); +} + +/** 一条素材参与路径匹配的全部候选:本地路径 + 序列帧地址(空值丢掉)。 */ +function assetCandidatePaths(asset: GameCreationAppAssetManifestEntry) { + const paths = asset.localPath ? [asset.localPath] : []; + for (const frame of asset.imageSequenceFrames ?? []) { + if (frame.imageSrc) { + paths.push(frame.imageSrc); + } + } + return paths; +} + +export function matchRuntimeInspectResources( + input: RuntimeInspectMatchInput, +): RuntimeInspectMatchResult { + const knownIds = new Set(input.assets.map((asset) => asset.id)); + const matched: string[] = []; + + // 档 1:显式 id 里登记在 manifest 的那些,按输入顺序。 + for (const resourceId of input.resourceIds ?? []) { + if (knownIds.has(resourceId)) { + matched.push(resourceId); + } + } + + const sourceSegments = input.sourcePath ? pathSegments(input.sourcePath) : []; + const sourceFileName = sourceSegments[sourceSegments.length - 1]; + if (sourceFileName !== undefined) { + // 一趟扫完两档:后缀命中优先于同文件名命中,各自保持 manifest 顺序。 + const suffixMatches: string[] = []; + const fileNameMatches: string[] = []; + for (const asset of input.assets) { + let tier: 0 | 2 | 3 = 0; + for (const path of assetCandidatePaths(asset)) { + const segments = pathSegments(path); + // 档 2:精确后缀匹配,段边界即后缀边界(`b/hero.png` 不会命中 `a/hero.png`)。 + if (endsWithSegments(segments, sourceSegments)) { + tier = 2; + break; + } + // 档 3:文件名匹配(现有口径);重名素材全部返回,不静默只取一条。 + if ( + segments[segments.length - 1]?.toLowerCase() === + sourceFileName.toLowerCase() + ) { + tier = 3; + } + } + if (tier === 2) { + suffixMatches.push(asset.id); + } else if (tier === 3) { + fileNameMatches.push(asset.id); + } + } + matched.push(...suffixMatches, ...fileNameMatches); + } + + return { resourceIds: [...new Set(matched)] }; +} + +/** + * 点选载荷的 `label`:**唯一确定素材**时用 manifest 的显示名统一它,否则原样保留桥给的 label。 + * + * 「这次点选是不是引擎档」由桥的硬编码标记回答:`engineInspectSelection` 把 `elementTag` 固定成 + * `'canvas'`,DOM 档给的是元素自己的标签(桥源码见 `src-tauri/resources/preview/local-preview-fit.js`)。 + * + * 引擎档的 label 可能是文件名词干(显式 `genarrativeResourcePath` / 贴图 URL pathname 派生), + * 也可能退化成 Phaser 的 `texture.key` 或常量 `'canvas'`——后者不是文件名,没法与 + * `resourceDisplayName` 逐字比对;但「已唯一命中 manifest 素材」本身就是定名依据,所以引擎档不看 + * label 形状,直接归一化(主规范第 2 条:同一素材的显示名与 `@` 候选 / 资源卡「引用」完全一致)。 + * 实测依据:Phaser 4 的 loader 走 XHR + `blob:` URL,贴图来源读不回原始地址,label 必然落到 + * `texture.key`(见本轮 Chromium 冒烟记录)。 + * + * DOM 档的 label 是元素文本或标签(`text || tag`):只有它恰好就是 `sourcePath` 的文件名词干时 + * 才做同一次归一化(避免 `Hero.PNG` 这类大小写差异),其余一律原样保留——DOM 点选行为逐字不变 + * (主规范第 1 条)。 + */ +export function resolveRuntimeInspectLabel(input: { + assets: readonly GameCreationAppAssetManifestEntry[]; + resourceIds: readonly string[]; + sourcePath?: string | null; + label: string; + /** 选中元素的标签:引擎档固定 `'canvas'`,DOM 档是元素标签。 */ + elementTag?: string | null; +}): string { + if (input.resourceIds.length !== 1) { + return input.label; + } + const asset = input.assets.find( + (candidate) => candidate.id === input.resourceIds[0], + ); + if (!asset) { + return input.label; + } + const stem = sourceFileNameStem(input.sourcePath); + const labelIsFileNameStem = + Boolean(stem) && input.label.trim().toLowerCase() === stem.toLowerCase(); + if (input.elementTag !== 'canvas' && !labelIsFileNameStem) { + return input.label; + } + return resourceDisplayName(asset); +} 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 50e0c00dc..89274a822 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 @@ -143,6 +143,10 @@ import { resourceLabelResolver, resourceReferenceCategoryLabel, } from '../../features/project-workspace/resourceReferences'; +import { + matchRuntimeInspectResources, + resolveRuntimeInspectLabel, +} from '../../features/project-workspace/runtimeInspectResourceMatch'; import { useElementFullscreen } from '../../features/project-workspace/useElementFullscreen'; import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; import { @@ -2795,28 +2799,27 @@ export default function ProjectDevelopmentView({ }, [embeddedPreviewUrl, mode]); const handleRuntimeInspectSelection = useCallback( (selection: LocalGamePreviewInspectSelection) => { - const sourceName = selection.sourcePath?.split('/').pop()?.toLowerCase(); - const matchedResourceIds = sourceName - ? manifest.assets - .filter( - (asset) => - asset.localPath.split(/[\\/]/u).pop()?.toLowerCase() === - sourceName, - ) - .map((asset) => asset.id) - : []; + const { resourceIds } = matchRuntimeInspectResources({ + assets: manifest.assets, + sourcePath: selection.sourcePath, + resourceIds: selection.resourceIds, + }); dispatchResourceReferenceInsert({ type: 'runtime-region', - label: selection.label, + label: resolveRuntimeInspectLabel({ + assets: manifest.assets, + resourceIds, + sourcePath: selection.sourcePath, + label: selection.label, + elementTag: selection.elementTag, + }), runId: preview?.port ? `preview-${preview.port}` : undefined, elementTag: selection.elementTag, elementRole: selection.elementRole, text: selection.text, width: selection.width, height: selection.height, - resourceIds: [ - ...new Set([...selection.resourceIds, ...matchedResourceIds]), - ], + resourceIds, source: 'runtime-picker', }); setRuntimeInspectMode(false); diff --git a/apps/ai-game-creator-shell/tests/runtimeInspectResourceMatch.test.ts b/apps/ai-game-creator-shell/tests/runtimeInspectResourceMatch.test.ts new file mode 100644 index 000000000..b8f4c13b8 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/runtimeInspectResourceMatch.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from 'vitest'; + +import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + matchRuntimeInspectResources, + resolveRuntimeInspectLabel, +} from '../src/features/project-workspace/runtimeInspectResourceMatch'; + +function asset( + id: string, + localPath: string, + frameSrcs: string[] = [], +): GameCreationAppAssetManifestEntry { + return { + id, + kind: 'character', + mediaType: 'image/png', + localPath, + source: { kind: 'uploaded' }, + imageSequenceFrames: frameSrcs.map((imageSrc) => ({ + imageSrc, + width: 32, + height: 32, + })), + }; +} + +const hero = asset('hero', 'assets/hero.png'); +const enemy = asset('enemy', 'assets/enemy.png'); +const manifest = [hero, enemy]; + +describe('运行画面点选 → manifest 素材 id', () => { + it('显式 id 优先:登记在 manifest 的按输入顺序保留,未知 id 一律丢弃', () => { + expect( + matchRuntimeInspectResources({ + assets: manifest, + resourceIds: ['ghost', 'hero'], + sourcePath: '/assets/enemy.png', + }), + ).toEqual({ resourceIds: ['hero', 'enemy'] }); + // 全是未知 id:宁可给空,也不能把值透传给 Rust(未知 id 会让整条引用被拒)。 + expect( + matchRuntimeInspectResources({ + assets: manifest, + resourceIds: ['ghost'], + }), + ).toEqual({ resourceIds: [] }); + }); + + it('精确后缀匹配:深层路径以段边界命中短 localPath', () => { + expect( + matchRuntimeInspectResources({ + assets: [asset('hero', 'game/public/assets/hero.png')], + sourcePath: '/assets/hero.png', + }), + ).toEqual({ resourceIds: ['hero'] }); + // `/` 与 `\` 都是分隔符,两侧混用同样按段比较(大小写不敏感)。 + expect( + matchRuntimeInspectResources({ + assets: [asset('hero', 'game\\public\\assets\\hero.png')], + sourcePath: '/ASSETS/HERO.PNG', + }), + ).toEqual({ resourceIds: ['hero'] }); + }); + + it('段边界不误配:后缀档先于文件名档,`minigame/hero.png` 不当作 `game/hero.png` 的后缀', () => { + expect( + matchRuntimeInspectResources({ + assets: [ + asset('mini', 'minigame/hero.png'), + asset('game', 'game/hero.png'), + ], + sourcePath: '/game/hero.png', + }), + ).toEqual({ resourceIds: ['game', 'mini'] }); + expect( + matchRuntimeInspectResources({ + assets: [asset('a', 'a/hero.png'), asset('ab', 'ab/hero.png')], + sourcePath: '/ab/hero.png', + }), + ).toEqual({ resourceIds: ['ab', 'a'] }); + }); + + it('重名素材命中多条时全部返回,不静默只取一条', () => { + expect( + matchRuntimeInspectResources({ + assets: [ + asset('first', 'art/a/hero.png'), + asset('second', 'art/b/hero.png'), + ], + sourcePath: '/other/hero.png', + }), + ).toEqual({ resourceIds: ['first', 'second'] }); + }); + + it('序列帧 `imageSrc` 与 `localPath` 同权参与后缀与文件名匹配', () => { + const walk = asset('walk', 'assets/hero.png', ['assets/walk/frame_01.png']); + expect( + matchRuntimeInspectResources({ + assets: [walk], + sourcePath: '/game/assets/walk/frame_01.png', + }), + ).toEqual({ resourceIds: ['walk'] }); + expect( + matchRuntimeInspectResources({ + assets: [walk], + sourcePath: '/elsewhere/frame_01.png', + }), + ).toEqual({ resourceIds: ['walk'] }); + // 序列帧不命中时不能靠 localPath 蹭上:这里文件名与帧地址都不同。 + expect( + matchRuntimeInspectResources({ + assets: [walk], + sourcePath: '/assets/other.png', + }), + ).toEqual({ resourceIds: [] }); + }); + + it('无匹配时返回空数组', () => { + expect( + matchRuntimeInspectResources({ + assets: manifest, + sourcePath: '/assets/missing.png', + }), + ).toEqual({ resourceIds: [] }); + expect(matchRuntimeInspectResources({ assets: manifest })).toEqual({ + resourceIds: [], + }); + expect( + matchRuntimeInspectResources({ + assets: [], + sourcePath: '/assets/hero.png', + }), + ).toEqual({ resourceIds: [] }); + }); + + it('显式 id 与路径命中撞上同一素材时去重', () => { + expect( + matchRuntimeInspectResources({ + assets: manifest, + resourceIds: ['hero', 'hero'], + sourcePath: '/assets/hero.png', + }), + ).toEqual({ resourceIds: ['hero'] }); + }); +}); + +describe('运行画面点选载荷的 label', () => { + // 引擎档证据:Phaser 4 loader 走 XHR + blob URL,贴图 URL 读不回来,桥的 label 会落到 + // `texture.key`(`decoy`)或常量 `'canvas'`。唯一命中素材时这两类都要按 manifest 显示名定名。 + it('引擎档(elementTag=canvas)唯一命中素材时,无论 label 是不是文件名派生,都归一化成资源显示名', () => { + expect( + resolveRuntimeInspectLabel({ + assets: manifest, + resourceIds: ['enemy'], + sourcePath: '/assets/decoy.png', + label: 'decoy', + elementTag: 'canvas', + }), + ).toBe('enemy'); + // 贴图 URL 读不回来(sourcePath 缺失)时同样定名。 + expect( + resolveRuntimeInspectLabel({ + assets: manifest, + resourceIds: ['enemy'], + label: 'decoy', + elementTag: 'canvas', + }), + ).toBe('enemy'); + expect( + resolveRuntimeInspectLabel({ + assets: manifest, + resourceIds: ['hero'], + label: 'canvas', + elementTag: 'canvas', + }), + ).toBe('hero'); + }); + + it('label 是文件名派生的词干时归一化(含空白折 `-`、大小写取 manifest 的 localPath)', () => { + expect( + resolveRuntimeInspectLabel({ + assets: manifest, + resourceIds: ['hero'], + sourcePath: '/assets/hero.png', + label: 'hero', + elementTag: 'canvas', + }), + ).toBe('hero'); + expect( + resolveRuntimeInspectLabel({ + assets: [asset('hero-v2', 'assets/hero v2.png')], + resourceIds: ['hero-v2'], + sourcePath: '/assets/hero v2.png', + label: 'hero v2', + elementTag: 'canvas', + }), + ).toBe('hero-v2'); + expect( + resolveRuntimeInspectLabel({ + assets: manifest, + resourceIds: ['hero'], + sourcePath: '/assets/Hero.PNG', + label: 'Hero', + elementTag: 'canvas', + }), + ).toBe('hero'); + // 非 canvas 的选点(DOM 档)恰好在媒体 `src` 上、且 label 逐字等于文件名时,同样归一化。 + expect( + resolveRuntimeInspectLabel({ + assets: manifest, + resourceIds: ['hero'], + sourcePath: '/assets/Hero.PNG', + label: 'Hero', + elementTag: 'img', + }), + ).toBe('hero'); + }); + + it('DOM 档的元素文本 / 标签不是文件名派生时原样保留(DOM 点选行为逐字不变)', () => { + expect( + resolveRuntimeInspectLabel({ + assets: manifest, + resourceIds: ['hero'], + sourcePath: '/assets/hero.png', + label: '开始按钮', + elementTag: 'button', + }), + ).toBe('开始按钮'); + expect( + resolveRuntimeInspectLabel({ + assets: manifest, + resourceIds: ['hero'], + sourcePath: '/assets/hero.png', + label: 'img', + elementTag: 'img', + }), + ).toBe('img'); + expect( + resolveRuntimeInspectLabel({ + assets: manifest, + resourceIds: ['hero'], + label: 'HUD 区域', + elementTag: 'div', + }), + ).toBe('HUD 区域'); + }); + + it('匹配不到唯一素材时不改 label', () => { + expect( + resolveRuntimeInspectLabel({ + assets: [asset('first', 'a/hero.png'), asset('second', 'b/hero.png')], + resourceIds: ['first', 'second'], + sourcePath: '/other/hero.png', + label: 'Hero', + elementTag: 'canvas', + }), + ).toBe('Hero'); + expect( + resolveRuntimeInspectLabel({ + assets: manifest, + resourceIds: [], + sourcePath: '/assets/hero.png', + label: 'hero', + elementTag: 'canvas', + }), + ).toBe('hero'); + expect( + resolveRuntimeInspectLabel({ + assets: manifest, + resourceIds: ['ghost'], + sourcePath: '/assets/hero.png', + label: 'hero', + elementTag: 'canvas', + }), + ).toBe('hero'); + }); +});