Files
Genarrative/apps/ai-game-creator-shell/src/features/project-workspace/runtimeInspectResourceMatch.ts
T
suzmii 6264fed6b4 运行画面点选结果收敛到 manifest 现有素材 id
- 新增匹配纯函数:显式 id、精确路径后缀、文件名、序列帧地址四档,未知值一律丢弃
- 恰好命中一个素材且标签由文件名派生时,芯片名归一化为资源显示名
- 点选处理改为调用该纯函数,派发载荷字段与顺序不变
- 新增匹配与标签归一化的单元用例
2026-10-04 20:50:26 +08:00

174 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}