Merge remote-tracking branch 'origin/master' into feat/game-fork
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m31s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m20s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m3s
Project CI / Frontend tests (pull_request) Successful in 2m54s
Project CI / Backend tests (pull_request) Successful in 7m36s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m26s
Project CI / Repository checks (pull_request) Successful in 6m25s
Project CI / Native shell tests (pull_request) Successful in 9m34s

# Conflicts:
#	deploy/container/nginx.conf
#	deploy/nginx/genarrative-dev-http.conf
#	deploy/nginx/genarrative.conf
#	server-rs/crates/api-server/src/modules/game_distribution.rs
#	src/components/game-distribution/GameDetailPage.tsx
#	src/components/game-distribution/GameDistributionPages.test.tsx
#	src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
This commit is contained in:
2026-10-06 00:19:40 +08:00
131 changed files with 15727 additions and 5835 deletions
@@ -16,7 +16,7 @@ Use `agc_apply_patch` for targeted source edits with the official Add/Delete/Upd
1. **Brief and scope** — Read the current planning output and project instructions. Record this turn's required game loop, actions, visuals, viewports and completion evidence. Use a bounded first-delivery scope; do not add new features or artwork during final review. For a new Web project, start from the prepared project scaffold. Do not repeat environment preflight unless new evidence requires it; `agc_environment_check` remains available for diagnosis. If blocked, report the toolchain/browser issue; do not download a second toolchain or search the whole machine. Read-only diagnosis and editor projects do not require this Web preflight.
2. **Project and asset inventory** — Inspect the existing project structure and call `agc_list_registered_assets` (and `agc_list_project_files` when needed). Record which requested visuals already have usable registered identities and which are missing. Do not invent asset identities from filenames.
3. **Visual production** — For missing or unsuitable visuals, call the reviewed `agc_tools` workflow: use `taonier_prepare_game_art` for a complete package, or `agc_generate_image` / `agc_edit_image` for focused assets. Read returned paths, identities, and warnings. A warning or partial package requires a narrower retry or independent assets before continuing.
4. **Game implementation** — Implement the complete playable loop and wire the returned project-relative asset paths into the actual runtime. Every required character, object, background, effect, and UI visual must have a real source or an explicit brief-level decision to remain code-native. Generated assets that are unused, documentation-only, or replaced by emoji/CSS placeholders do not satisfy this stage.
4. **Game implementation** — Implement the complete playable loop and wire the returned project-relative asset paths into the actual runtime. Every required character, object, background, effect, and UI visual must have a real source or an explicit brief-level decision to remain code-native. Generated assets that are unused, documentation-only, or replaced by emoji/CSS placeholders do not satisfy this stage. Web games (Phaser 4 / three.js) must also satisfy the 运行画面点选契约 in `agc-web-game-development`: publish the read-only engine handle, load Phaser images with `loader.imageLoadType='HTMLImageElement'`, annotate asset-rendered objects with the manifest identity, and keep media at a stable dist path aligned with `localPath`; without it 点选 only degrades to a region reference without an asset id.
5. **Build and local verification** — Build and test as needed using the available command tools or `agc_run_validation`. Inspect actual command results and fix build or asset-loading failures before preview. Build/test return values and file existence are not contract requirements; visual/gameplay checks still require their existing trusted browser evidence.
6. **Validation** — Use the approved browser tool and fixed scenarios. Call `agc_browser_playtest` with `mode=visual` for art/layout checks or `mode=gameplay` for fixed real-input/state/restart checks. Use `agc_run_validation` for existing focused Node/npm tests when needed. Fix blocking findings and rerun only the affected layer after an actual change. Do not rerun a whole playthrough for a color or documentation edit. A visual pass does not establish gameplay or complete-level coverage.
7. **Delivery** — Once required evidence matches the current input, stop and report the observed validation scope. Do not start another side effect, art cycle, or polish cycle. A fixed scenario is not a full long-level playthrough. List new nonblocking ideas as follow-up work. Missing required evidence remains an explicit gap.
@@ -27,6 +27,24 @@ Make targeted source changes with `agc_apply_patch` using the official patch syn
When implementing a new game loop or a broad gameplay revision, read `references/game-quality-checklist.md`.
## 运行画面点选契约(运行态素材身份)
客户端「点选素材」把运行画面里点中的对象落成一条 `runtime-region` 引用;命中已登记素材时引用带上该素材的稳定 id,显示名与 `@` 候选、资源卡「引用」一致。Phaser 4 与 three.js 项目都要满足下面四条,新生成的游戏才对点选可用。
1. **发布只读引擎句柄**:引擎实例创建后发布一次,只服务点选与调试,不承载业务状态。句柄缺失或 `engine` 不是 `phaser` / `three` 时点选按第 4 条退化。
- Phaser:`window.__GENARRATIVE_PREVIEW_GAME__ = { engine: 'phaser', game };`
- three.js:`window.__GENARRATIVE_PREVIEW_GAME__ = { engine: 'three', three: { Raycaster, Vector2, Vector3, Box3 }, scene, camera, renderer };`
- **不要传整个 `three` 命名空间**:实测同一个 Vite 构建因此从 517,682 B 涨到 730,831 B(+213,149 B ≈ +41%),tree-shaking 失效。
- 这四个构造器是预览桥做射线与包围盒投影的最小集;该字段只给预览桥用,玩法代码不要引用它。
- 旧形态 `{ engine: 'three', THREE, scene, camera, renderer }` 仍被兼容读取,新代码一律用上面的瘦身形态。
2. **Phaser 图片用 Image 标签装载**:游戏配置固定 `loader: { imageLoadType: 'HTMLImageElement' }`。默认的 XHR 装载把贴图地址换成会话内临时地址,点选读不回原始素材路径;这类项目只能靠第 3 条的显式标注。
3. **给渲染对象标注素材身份**:用已登记素材渲染的对象要带上 manifest 的稳定身份,值只取 `agc_list_registered_assets` 返回的 `localAssetId` / `localPath`,不自行编造、不改写文件名;素材 id 不含空白与路径分隔符(`localAssetId` 由客户端生成)。
- Phaser:`sprite.setData('genarrativeResourceId', '<localAssetId>')`,或 `sprite.setData('genarrativeResourcePath', '<localPath>')`;两者都给时 id 优先。
- three.js:`mesh.userData.genarrativeResourceId` / `mesh.userData.genarrativeResourcePath`;GLB、分组或关卡导入的模型给 `traverse` 到的实际渲染 mesh 标注。
4. **素材进 dist 走稳定路径**:素材放进游戏包 `public/`(脚手架 Vite 配置的默认 `publicDir`,原样拷贝、不加 hash),并保持与 `localPath` 相同的相对段(项目根 `assets/hero.png` → `game/public/assets/hero.png`,运行时用 `./assets/hero.png` 加载)。不要 `import` 图片或模型:构建产物名会带内容 hash,与 manifest 路径对不上,点选只剩文件名兜底,重名素材直接丢掉。
点选只在用户按下时读取上述信息;不满足时**不报错、不伪造 id**,退化为不带素材的区域引用,引用仍能插入、能发送。这四条就是点选能力的验收面。
# Notes
* Do not edit UI Editor JSON directly. If the user exports `ui/generated-<digest>.js`, read that file instead; its embedded documentation explains how to use it.
@@ -11,3 +11,4 @@
- 在构建后的实际页面检查桌面、移动和 resize:比较 canvas 与游戏父容器的中心,预期居中时水平/垂直误差不超过 1 CSS px,并检查画面没有溢出或意外滚动条。偏移先修游戏 CSS/scale 配置,不要靠调整预览位置掩盖布局问题。
- HUD and overlays reserve space and do not cover essential interactive content.
- Requested Taonier art is visibly integrated into the core experience when available.
- 运行画面点选契约已满足:发布只读引擎句柄、Phaser 图片用 Image 标签装载、素材渲染对象带 manifest 身份标注,且素材在 dist 里保持与 `localPath` 对齐的稳定路径(见主 Skill「运行画面点选契约」)。
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.40",
"version": "2026-08-26.42",
"skills": [
{
"name": "agc-unity-editor",
@@ -59,7 +59,7 @@
"agents/openai.yaml",
"references/workflow-contract.md"
],
"sha256": "0ee5c36276f442da527e7f56e8b2e89632aa9bb63cabbdd689b15724357b6119"
"sha256": "d1431d2a66261466d3c8f76e6be7803f34f2f982573f538690030229a6469953"
},
{
"name": "agc-project-structure",
@@ -121,7 +121,7 @@
"agents/openai.yaml",
"references/game-quality-checklist.md"
],
"sha256": "0e47c0e16d33f1cac66b959c7a574cc570674ab4a7555a8f732ea28842911397"
"sha256": "9959892b2b92a62aa5e8ca12a1938483a1bafc57c8b8e4520fed6547ed38d0de"
},
{
"name": "agc-browser-playtest",
File diff suppressed because it is too large Load Diff
@@ -1256,7 +1256,7 @@ const DEFAULT_GAME_INDEX_HTML: &str = r#"<!doctype html>
</html>
"#;
const DEFAULT_GAME_STYLE_CSS: &str = "body { margin: 0; display: grid; min-height: 100vh; place-items: center; background: #101827; color: #d9e7ff; font: 16px system-ui, sans-serif; }\nmain { width: min(720px, calc(100vw - 32px)); }\n";
const DEFAULT_GAME_SCRIPT_JS: &str = "import Phaser from 'phaser';\nimport './style.css';\n\nclass PlaceholderScene extends Phaser.Scene {\n create() { this.add.text(24, 24, '还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。'); }\n}\n\nnew Phaser.Game({ type: Phaser.AUTO, width: 720, height: 420, parent: 'game', scene: PlaceholderScene });\n";
const DEFAULT_GAME_SCRIPT_JS: &str = "import Phaser from 'phaser';\nimport './style.css';\n\nclass PlaceholderScene extends Phaser.Scene {\n create() { this.add.text(24, 24, '还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。'); }\n}\n\n// 用 Image 标签装载图片,让贴图保留原始素材地址,运行画面点选才能定位素材。\nconst game = new Phaser.Game({ type: Phaser.AUTO, width: 720, height: 420, parent: 'game', loader: { imageLoadType: 'HTMLImageElement' }, scene: PlaceholderScene });\n// 发布运行画面实例句柄,供预览桥点选定位游戏实例。\nwindow.__GENARRATIVE_PREVIEW_GAME__ = { engine: 'phaser', game };\n";
const DEFAULT_EDITOR_BASE_URL: &str = "http://127.0.0.1:3000";
const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json";
File diff suppressed because it is too large Load Diff
@@ -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);
}
@@ -144,6 +144,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 {
@@ -2860,28 +2864,27 @@ export default function ProjectDevelopmentView({
}, [embeddedPreviewUrl, mode, onPlay, projectPath, runAvailable]);
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);
@@ -18,6 +18,21 @@ renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
container.append(renderer.domElement);
// 发布运行画面实例句柄,供预览桥点选定位场景、相机与渲染器。
// three 字段只放预览桥要用的构造器:整命名空间会破坏 tree-shaking(实测包体 +41%)。
window.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'three',
three: {
Raycaster: THREE.Raycaster,
Vector2: THREE.Vector2,
Vector3: THREE.Vector3,
Box3: THREE.Box3,
},
scene,
camera,
renderer,
};
const light = new THREE.DirectionalLight(0xffffff, 1.2);
light.position.set(4, 8, 6);
scene.add(light, new THREE.AmbientLight(0x8899ff, 0.5));
@@ -12,10 +12,15 @@ class PlaceholderScene extends Phaser.Scene {
}
}
new Phaser.Game({
// 用 Image 标签装载图片,让贴图保留原始素材地址,运行画面点选才能定位素材。
const game = new Phaser.Game({
type: Phaser.AUTO,
width: 720,
height: 420,
parent: 'game',
loader: { imageLoadType: 'HTMLImageElement' },
scene: PlaceholderScene,
});
// 发布运行画面实例句柄,供预览桥点选定位游戏实例。
window.__GENARRATIVE_PREVIEW_GAME__ = { engine: 'phaser', game };
@@ -18,6 +18,21 @@ renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
container.append(renderer.domElement);
// 发布运行画面实例句柄,供预览桥点选定位场景、相机与渲染器。
// three 字段只放预览桥要用的构造器:整命名空间会破坏 tree-shaking(实测包体 +41%)。
window.__GENARRATIVE_PREVIEW_GAME__ = {
engine: 'three',
three: {
Raycaster: THREE.Raycaster,
Vector2: THREE.Vector2,
Vector3: THREE.Vector3,
Box3: THREE.Box3,
},
scene,
camera,
renderer,
};
const light = new THREE.DirectionalLight(0xffffff, 1.4);
light.position.set(3, 5, 4);
scene.add(light, new THREE.AmbientLight(0x8899ff, 0.6));
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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');
});
});