运行画面点选结果收敛到 manifest 现有素材 id
- 新增匹配纯函数:显式 id、精确路径后缀、文件名、序列帧地址四档,未知值一律丢弃 - 恰好命中一个素材且标签由文件名派生时,芯片名归一化为资源显示名 - 点选处理改为调用该纯函数,派发载荷字段与顺序不变 - 新增匹配与标签归一化的单元用例
This commit is contained in:
+173
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user