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); }