Merge branch 'master' into feat/creator-hub
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
This commit is contained in:
@@ -9,6 +9,8 @@ apps/ai-game-creator-shell/src/features/ui-editor/types/
|
||||
apps/ai-game-creator-shell/src/view/project-development/chat/generated/
|
||||
apps/ai-game-creator-shell/src/services/generated/
|
||||
apps/ai-game-creator-shell/src-tauri/resources/agc-skills/
|
||||
# 预览桥脚本:随包注入浏览器的资源,按字节搬运自 Rust 内联字符串,禁止 prettier 二次改写
|
||||
apps/ai-game-creator-shell/src-tauri/resources/preview/
|
||||
media
|
||||
*.log
|
||||
.preview.*
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { PLATFORM_GAME_LOADING_TIMEOUT_MS } from '@genarrative/shared/components';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
@@ -317,3 +319,99 @@ test('详情展示发布者和冻结资料,并试玩当前待审版本', async
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
/** 打开详情并点开试玩,返回试玩 iframe。 */
|
||||
async function openPendingVersionPreview() {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText(gameTitle);
|
||||
fireEvent.click(screen.getByRole('button', { name: '详情' }));
|
||||
await screen.findAllByText(/测试作者/u);
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' }));
|
||||
return screen.findByTitle('测试游戏 待审版本试玩');
|
||||
}
|
||||
|
||||
test('试玩期间先给加载面,待审版本真正载入后让位给画面', async () => {
|
||||
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue(detailResponse);
|
||||
vi.mocked(createAdminGameDistributionPreviewSession).mockResolvedValue({
|
||||
previewUrl:
|
||||
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/',
|
||||
expiresAt: '2026-10-02T12:10:00Z',
|
||||
versionId: 'version-1',
|
||||
});
|
||||
|
||||
const frame = await openPendingVersionPreview();
|
||||
|
||||
// 加载期不能只留一片空白:审核员要看到正在加载哪个游戏。
|
||||
const loadingSurface = screen.getByRole('status');
|
||||
expect(loadingSurface.textContent).toContain('测试游戏');
|
||||
expect(
|
||||
loadingSurface.classList.contains('platform-game-loading--panel'),
|
||||
).toBe(true);
|
||||
|
||||
fireEvent.load(frame);
|
||||
await waitFor(() => expect(screen.queryByRole('status')).toBeNull());
|
||||
});
|
||||
|
||||
test('试玩加载超过上限后给出超时说明与重建会话入口', async () => {
|
||||
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue(detailResponse);
|
||||
vi.mocked(createAdminGameDistributionPreviewSession)
|
||||
.mockResolvedValueOnce({
|
||||
previewUrl:
|
||||
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/',
|
||||
expiresAt: '2026-10-02T12:10:00Z',
|
||||
versionId: 'version-1',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
previewUrl:
|
||||
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-2/',
|
||||
expiresAt: '2026-10-02T12:20:00Z',
|
||||
versionId: 'version-1',
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText(gameTitle);
|
||||
fireEvent.click(screen.getByRole('button', { name: '详情' }));
|
||||
await screen.findAllByText(/测试作者/u);
|
||||
|
||||
// 试玩会话与超时定时器都必须落在假定时器窗口内,才能按上限推进等待。
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' }));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByTitle('测试游戏 待审版本试玩')).toBeTruthy();
|
||||
expect(screen.queryByText('试玩版本加载超时')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(PLATFORM_GAME_LOADING_TIMEOUT_MS);
|
||||
});
|
||||
expect(screen.getByRole('status').textContent).toContain(
|
||||
'试玩版本加载超时',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新创建试玩会话' }));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(createAdminGameDistributionPreviewSession).toHaveBeenCalledTimes(2);
|
||||
// 新会话必须回到加载态,不能沿用上一个会话的「已就绪」或超时结论。
|
||||
const rebuilt = screen.getByTitle('测试游戏 待审版本试玩');
|
||||
expect(rebuilt.getAttribute('src')).toBe(
|
||||
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-2/',
|
||||
);
|
||||
expect(screen.queryByText('试玩版本加载超时')).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
formatAdminDateTime,
|
||||
GameDetailDisplay,
|
||||
Modal,
|
||||
PLATFORM_GAME_LOADING_TIMEOUT_MS,
|
||||
PlatformGameLoadingSurface,
|
||||
TextField,
|
||||
} from '@genarrative/shared/components';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
@@ -591,6 +593,20 @@ function AdminGameDistributionReviewDetailView({
|
||||
onReject: () => void;
|
||||
onApprove: () => void;
|
||||
}) {
|
||||
// 试玩会话是按 version 现建的:新会话必须回到加载态,不能沿用上一个会话的 ready。
|
||||
const [isPreviewFrameReady, setIsPreviewFrameReady] = useState(false);
|
||||
const [isPreviewTimedOut, setIsPreviewTimedOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsPreviewFrameReady(false);
|
||||
setIsPreviewTimedOut(false);
|
||||
if (!previewUrl) return undefined;
|
||||
const timer = window.setTimeout(() => {
|
||||
setIsPreviewTimedOut(true);
|
||||
}, PLATFORM_GAME_LOADING_TIMEOUT_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [previewUrl]);
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<AdminPage wide>
|
||||
@@ -606,6 +622,10 @@ function AdminGameDistributionReviewDetailView({
|
||||
);
|
||||
}
|
||||
|
||||
const coverUrl = detail.game.coverObjectKey
|
||||
? assetUrls[detail.game.coverObjectKey]
|
||||
: '';
|
||||
|
||||
return (
|
||||
<AdminPage wide>
|
||||
<AdminPageHeading
|
||||
@@ -682,13 +702,46 @@ function AdminGameDistributionReviewDetailView({
|
||||
<AdminAlert tone="warning">{previewError}</AdminAlert>
|
||||
) : null}
|
||||
{previewUrl ? (
|
||||
<iframe
|
||||
title={`${detail.game.title} 待审版本试玩`}
|
||||
src={previewUrl}
|
||||
sandbox="allow-scripts"
|
||||
referrerPolicy="no-referrer"
|
||||
className="admin-game-review-preview"
|
||||
/>
|
||||
<div className="admin-game-review-preview-shell">
|
||||
{isPreviewFrameReady ? null : (
|
||||
<PlatformGameLoadingSurface
|
||||
tone="panel"
|
||||
title={detail.game.title}
|
||||
cover={
|
||||
coverUrl ? (
|
||||
<img alt={`${detail.game.title} 封面`} src={coverUrl} />
|
||||
) : null
|
||||
}
|
||||
message={isPreviewTimedOut ? '试玩版本加载超时' : undefined}
|
||||
hint={
|
||||
isPreviewTimedOut
|
||||
? '可以重新创建试玩会话,或稍后再试。'
|
||||
: undefined
|
||||
}
|
||||
stalled={isPreviewTimedOut}
|
||||
/>
|
||||
)}
|
||||
<iframe
|
||||
title={`${detail.game.title} 待审版本试玩`}
|
||||
src={previewUrl}
|
||||
sandbox="allow-scripts"
|
||||
referrerPolicy="no-referrer"
|
||||
className="admin-game-review-preview"
|
||||
onLoad={() => setIsPreviewFrameReady(true)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{previewUrl && isPreviewTimedOut && !isPreviewFrameReady ? (
|
||||
<AdminActionRow className="admin-review-actions">
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={isPreviewLoading}
|
||||
onClick={onPreview}
|
||||
>
|
||||
{isPreviewLoading ? '准备试玩…' : '重新创建试玩会话'}
|
||||
</AdminButton>
|
||||
</AdminActionRow>
|
||||
) : null}
|
||||
</section>
|
||||
</GameDetailDisplay>
|
||||
|
||||
@@ -3590,16 +3590,28 @@ button:disabled {
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.admin-game-review-preview-shell {
|
||||
/* 加载面(PlatformGameLoadingSurface)按绝对定位铺在预览区上,外壳必须是定位祖先。 */
|
||||
position: relative;
|
||||
margin-top: 1rem;
|
||||
min-height: 42rem;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.admin-game-review-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 42rem;
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.admin-game-review-preview-shell .platform-game-loading {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* 游戏管理列表(AdminGameManagementPage)专属排布,选择器全部框在本页表格内,
|
||||
* 不影响其它后台页面:
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+18
@@ -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.
|
||||
|
||||
|
||||
+1
@@ -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
@@ -1254,7 +1254,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
+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);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
+6
-1
@@ -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 };
|
||||
|
||||
+15
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -104,7 +104,9 @@ curl -sSI -H 'Accept-Encoding: br' \
|
||||
|
||||
- 现役发行入口是平台同源路径 `https://<平台域名>/games/<gameId>/`。三份常驻模板(`genarrative.conf`、`genarrative-dev-http.conf`、容器 `deploy/container/nginx.conf`)都内联同一条同源发行入口 location,把 `/games/<gameId>/` 与 `/games/<gameId>/<asset>` 转发到 `api-server` 发行网关;不再需要独立发行域名、`*.games.<域名>` 通配 DNS 或通配 TLS。
|
||||
- 该 location 的正则必须整体加双引号:`location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$"`。不加引号时 nginx 会把 `{32}` 当块定界符,`nginx -t` 报 `pcre2_compile() failed: missing closing parenthesis`。
|
||||
- 发行入口不使用 Cookie:边缘转发前设置 `proxy_set_header Cookie ""`;`api-server` 发行网关也会拒绝带 Cookie 的请求。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。
|
||||
- 发行入口不使用 Cookie:边缘转发前设置 `proxy_set_header Cookie ""`;`api-server` 发行网关也会拒绝带 Cookie 的请求。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单、`ETag` 与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。
|
||||
- 传输编码:发行网关自己对文本类资源(HTML/JS/CSS/JSON/SVG/WASM,≥1 KiB)下发 `Content-Encoding: gzip` + `Vary: Accept-Encoding`,边缘的 gzip/Brotli 不会二次压缩(两边都以「上游已带 `Content-Encoding` 就跳过」收口),因此本地 dev(Vite 代理)与 nginx 边缘的用户口径一致,不再有「本地引擎包原样 1.31 MiB」的落差。注意 **Pingora 网关在自身压缩关闭时会移除请求里的 `Accept-Encoding`**,那种部署形态下源站压缩不生效、边缘也不压,属于网关侧口径(见 `docs/project-memory/shared-memory/pitfalls.md` 2026-10-05 条目)。
|
||||
- 条件请求:命中 `If-None-Match` 时发行网关直接返回 `304`(无正文,保留 `Cache-Control` 与 `ETag`),所以 60 秒 `max-age` 之后的重复游玩只重验证、不重下整包;下架与换版仍按 60 秒窗口在新请求上生效。
|
||||
- 隔离靠 iframe 沙箱而不是独立来源:游戏文档跑在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载。
|
||||
- 审核通过时 `api-server` 按 gameId 派生同源路径 `/games/<gameId>/` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。
|
||||
- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/creators`、`/creators/connections`、`/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`、`/pay`、`/profile/payment`)与收银台深链前缀路由 `location ~* "^/pay/[^/]+/?$"`(`/pay/<checkoutToken>` 只放行「前缀 + 恰好一个路径段」;只放行裸前缀会让真实收银台链接落到默认 location 变 404),脚本自带正/反用例。历史上的独立来源模板与专属门禁已随同源方案上线删除。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AI 游戏创作项目开发工作台 PRD
|
||||
|
||||
更新时间:`2026-09-21`(2026-09-20 音频生成并入图片类那份后台任务账本、派生/修改类任务并入同一「生成任务」侧栏,2026-09-21 卡片浮层改为「提交即关」、**「生成任务」侧栏从画布左侧贴边改为画布右上角锚点(开关常驻)**、**资源卡可拖到对话栏批量 @ 引用**、**替换面板改为非模态浮层并支持在画布上点选目标**(验收现场两条修正:任务锚点改挂画布那一格、展开后开关让位),见 §3.10 / §5.3 / §7.8 / §7.9 与 [`【功能说明】AGC聊天素材引用`](../【功能说明】AGC聊天素材引用-2026-09-08.md);2026-09-14 图片类生成后台化:入口 IPC 为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变;2026-09-21 Godot 工作区发现放宽:一层多命中按目录名排序取第一个,`project.godot` 允许是链接/reparse point/硬链接,见 §3.8)
|
||||
更新时间:`2026-10-04`(2026-10-04 运行视窗「点选素材」补上 Phaser 4 / three.js 画面上的已登记素材识别与退化口径,见 §3.4;2026-09-20 音频生成并入图片类那份后台任务账本、派生/修改类任务并入同一「生成任务」侧栏,2026-09-21 卡片浮层改为「提交即关」、**「生成任务」侧栏从画布左侧贴边改为画布右上角锚点(开关常驻)**、**资源卡可拖到对话栏批量 @ 引用**、**替换面板改为非模态浮层并支持在画布上点选目标**(验收现场两条修正:任务锚点改挂画布那一格、展开后开关让位),见 §3.10 / §5.3 / §7.8 / §7.9 与 [`【功能说明】AGC聊天素材引用`](../【功能说明】AGC聊天素材引用-2026-09-08.md);2026-09-14 图片类生成后台化:入口 IPC 为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变;2026-09-21 Godot 工作区发现放宽:一层多命中按目录名排序取第一个,`project.godot` 允许是链接/reparse point/硬链接,见 §3.8)
|
||||
|
||||
## 1. 产品定位
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
- 运行视窗必须占满中央工作区为游戏保留的可用区域。loopback 预览页通过客户端本地 preview server 注入的只读尺寸桥上报文档实际宽高;宿主只接受当前 iframe、当前 loopback origin 的固定版本消息,并将完整游戏文档等比缩放、居中放入视窗。iframe 首次适配后发生的真实内容增高或缩短仍必须被接受;仅浏览上下文宽高回灌或内容宽高未变化时保持当前状态,不触发重复渲染。
|
||||
- 窗口或中央区域尺寸变化后必须重新测量和适配;内容已经放得下时保持 `1:1`,不得无故放大。游戏文档宽高超过视窗时缩小整体画面,不显示 iframe 横向或纵向滚动条,也不得用单纯裁切替代完整展示。尺寸桥以根布局 `ResizeObserver` 为主,并在页面可见时每 `500ms` 至多探测 `512` 个元素作为绝对定位溢出的低频兜底;探测截断时不得用部分样本下调尺寸,viewport 耦合的 `100vh / 100% / bottom / right` 布局也不得形成自反馈。相同测量结果去重,不监听整页属性、文本或子节点突变;桥不读取项目正文、不修改 manifest、游戏文件或运行业务状态。桥脚本只能注入到真实 HTML 标签上下文,不能把脚本、样式、模板或注释中的 `</body>` / `</html>` 文本误判为结束标签;省略结束标签的 UTF-8 HTML 仍需安全注入。
|
||||
- 运行视窗右下角提供“全屏预览”:只把游戏画面那一格送进全屏,顶部页签、右侧对话和底部信息栏不跟着放大;再次点击该入口、按 `Esc` 或由宿主退出全屏都回到原布局。宿主没有 Fullscreen API 时整枚入口不渲染,不留点了没反应的按钮。
|
||||
- 运行视窗提供“点选素材”:把画面上点中的内容落成一条 `runtime-region` 引用插入对话,只影响这一次引用——不改资源选中、不改信息栏判据、不写 manifest 或版本、不重载预览,也不代表“该素材正在被这个位置使用”。预览页是 Phaser 4 或 three.js 玩法时,点中由**已登记素材**渲染的精灵、图集帧、贴图网格或模型,引用必须带该素材的稳定 id,显示名与其它引用入口一致;玩法没有发布引擎句柄、对象既没有标注也取不到贴图地址、或画面是程序化绘制时,退化为不带素材的区域引用——不报错、不伪造 id、引用照常能插入能发送;点选不得改变预览的内容尺寸与适配缩放,命中区域只取画布内可见部分。
|
||||
- 运行视窗下方的信息栏只在**有真实内容**时存在(当前判据是**资源选中态**:在资源画布或浮层资源面板里选中一张资源后切到运行页签仍保留,信息栏渲染它的只读字段;运行画面上的“点选素材”只往对话插入引用,不改选中):没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;不显示示例字段、默认数值、未载入控件或功能说明。暂时没有数据源的区域(「数值微调」的登记表)不渲染区域标题与卡片,等编辑态登记表接进来后与内容一起出现。Agent 对话标题栏不显示头像图标,“与陶泥儿的对话”及副标题按标题栏左侧对齐,钱包和审批入口继续位于右侧。
|
||||
|
||||
- 数值修改立即写入当前项目的编辑态配置。
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# 【实施计划】AGC 运行画面点选识别 Phaser 4 与 three.js 画面上的已登记素材
|
||||
|
||||
| 字段 | 值 |
|
||||
| --------- | ----------------------------------------------------------------------- |
|
||||
| Milestone | `docs/project-memory/plans/【里程碑】AGC运行画面点选引擎适配-2026-10-04.md` |
|
||||
| Status | in-progress |
|
||||
| Owner | omp agent(issue #612) |
|
||||
|
||||
## 修改边界
|
||||
|
||||
允许修改:
|
||||
|
||||
- `apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js`(新增):现有预览桥脚本(尺寸上报 + DOM 点选)整体迁入,并新增 canvas 分支与两个引擎适配器。
|
||||
- `apps/ai-game-creator-shell/src-tauri/src/preview.rs`:`PREVIEW_FIT_BRIDGE_SCRIPT` 改为 `include_str!` 引用上面的文件;注入、去重扫描、`/__genarrative/local-preview-fit.js` 路由与响应逻辑不变。
|
||||
- `apps/ai-game-creator-shell/src/features/project-workspace/runtimeInspectResourceMatch.ts`(新增):把宿主侧「点选结果 → manifest 素材 id」的匹配抽成纯函数。
|
||||
- `apps/ai-game-creator-shell/src/view/project-development/index.tsx`:`handleRuntimeInspectSelection` 改为调用该纯函数,派发载荷结构不变。
|
||||
- `apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts`、`apps/ai-game-creator-shell/tests/runtimeInspectResourceMatch.test.ts`(新增)。
|
||||
- `apps/ai-game-creator-shell/src-tauri/src/main.rs`:`DEFAULT_GAME_SCRIPT_JS` 补引擎句柄发布。
|
||||
- `apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/game.js`、`.../threejs-3d-starter/project/game/main.js`:补引擎句柄发布(只改本地模板源,不重新发布 zip)。
|
||||
- 文档收口:`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`(「当前已完成」清单与本里程碑证据表)、`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` §3.4(如行为与草案有出入)。
|
||||
|
||||
明确不修改:
|
||||
|
||||
- `runtime-region` 引用契约、`genarrative.local-preview-inspect.v1` 消息版本号、`LocalGamePreviewFrame.tsx` 的解析与净化、Rust `direct_codex_user_item` 的校验口径(不新增字段;若实测必须加字段,单独评审)。
|
||||
- `packages/shared`、manifest / 版本绑定 / 素材替换链路、Runtime Prompt 文案、模板 zip 发布与线上索引(AGC 玩法 Skill 的生成期契约属阶段二,见下 `## 阶段二进展`)。
|
||||
- DOM 点选语义与 `enabled / disabled / cancelled` 协议。
|
||||
|
||||
## 实现顺序
|
||||
|
||||
1. **搬运桥脚本(行为零变化)**:把 `PREVIEW_FIT_BRIDGE_SCRIPT` 的字符串体原样落到 `resources/preview/local-preview-fit.js`,`preview.rs` 改 `include_str!`。跑 Rust 定向测试 + 一次真机 DOM 点选,确认尺寸上报与 DOM 点选完全不变。
|
||||
2. **拆出命中解析**:桥内把 `handleInspectMove` / `handleInspectClick` 共同的部分抽成 `resolveInspectTarget(clientX, clientY)`,返回 `{ selection, rect }`;DOM 分支逻辑等价搬运。
|
||||
3. **引擎句柄契约**:桥惰性读取 `window.__GENARRATIVE_PREVIEW_GAME__ = { engine: 'phaser', game }` 或 `{ engine: 'three', THREE, scene, camera, renderer }`(`THREE` 命名空间要求见下「风险」第 2 条);只认显式句柄,不做鸭子类型猜引擎。缺句柄或 `engine` 未知 → 走第 6 步退化。
|
||||
4. **Phaser 4 适配**:`game.input.activePointer` 取指针 → `scene.input.hitTestPointer(pointer)` 优先;无命中对当前激活场景的 `scene.children.list` 逆序(含 `depth`)用 `getBounds()` 与指针世界坐标求交兜底。素材身份:`getData('genarrativeResourceId' | 'genarrativeResourcePath')` 优先,其次 `texture.getSourceImage()` / `texture.source[].image` 的 `currentSrc || src` 与 `texture.key` + `frame.name`。矩形:`getBounds()` 世界矩形按相机换算屏幕矩形。多场景只取激活场景;全程 try/catch,任何一步抛错按"未命中"处理。
|
||||
5. **three.js 适配**:`renderer.domElement` 矩形归一化出 NDC → `new THREE.Raycaster()` + `setFromCamera` → `intersectObjects(scene.children, true)` 取最近命中。素材身份:`object.userData.genarrativeResourceId | genarrativeResourcePath` 优先,其次沿 `object.material` 的贴图槽取 `texture.image.currentSrc || texture.source.data.currentSrc`。矩形:命中对象 `Box3` 八角投影取 min/max。
|
||||
6. **退化路径**:以上任一步拿不到结果时,按现状输出(`label` = 元素/画面可得的名字,`sourcePath` / `resourceIds` 为空,矩形 = 现状 DOM 语义),不报错、不伪造 id。
|
||||
7. **宿主匹配抽纯函数**:`runtimeInspectResourceMatch.ts` 实现优先级——显式 `resourceIds`(已按 manifest 过滤)→ `sourcePath` 与 `localPath` 的精确后缀匹配 → 文件名匹配(现有口径);序列帧 `imageSequenceFrames[].imageSrc` 参与后两档;结果去重且只保留 manifest 现有素材 id。`index.tsx` 改为调用它。
|
||||
8. **脚手架与模板句柄**:`main.rs` 默认脚本与两个模板源补发布句柄(Phaser 在 `new Phaser.Game(...)` 处持有实例;three.js 在场景装配后发布)。
|
||||
9. **真机与文档收口**:两条 smoke(见下),补「当前已完成」清单与证据表。
|
||||
|
||||
## 验证命令
|
||||
|
||||
1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --offline -- preview`
|
||||
2. `npx vitest run apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts apps/ai-game-creator-shell/tests/runtimeInspectResourceMatch.test.ts apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`
|
||||
3. `npm run agc:typecheck`(含 `check:tests:types`、`tsc`、skill-pack 指纹、包布局声明、`check-config`)
|
||||
4. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check`
|
||||
5. 真机:`npm run agc` 拉起客户端 → 新建 Phaser 4 项目(默认脚手架)与 three.js 项目各点一次已登记素材,核对引用芯片名与载荷 `resourceIds`,并各点一次空白与 DOM HUD 确认退化与不回归。
|
||||
|
||||
## 风险与回滚点
|
||||
|
||||
1. **桥脚本迁移**(顺序 1):纯搬运,但桥同时承载尺寸上报(`resolveLocalGamePreviewFitLayout` 依赖它),任何字符差异都可能表现为画面适配回归。回滚点:该步单独一次提交,红了就 revert 该提交再重做。
|
||||
2. **three.js 需要 `THREE` 命名空间**:Raycaster / Vector2 / Box3 都是模块内类,桥拿不到;从 `camera.constructor` 只能拿到相机类,因此句柄契约要求同时暴露 `THREE`。若实测希望避免暴露命名空间,退路是要求玩法暴露少量构造器(`Raycaster`)——需要改契约,先评审再动手。
|
||||
3. **Phaser 4 相机换算**:世界↔屏幕矩形依赖 4.2.1 的相机字段与缩放行为;实测不稳定时降级为"命中对象但只给点标记矩形",不阻塞引用插入(主规范第 5 条的退化分支)。
|
||||
4. **`hitTestPointer` 只覆盖 `setInteractive()` 对象**:几何兜底必须存在,否则未开交互的精灵点不中。
|
||||
5. **three.js 递归 raycast 成本**:只在点选模式下按需执行(move 节流),不做常驻监听。
|
||||
6. **three.js 真机验收的模板来源**:本里程碑只改本地模板源,不重新发布 `template.zip`;three.js smoke 用模板工程 + 手工补一行句柄进行(句柄就是对外契约),正式"开箱可用"由下一个里程碑的模板发布覆盖。这条限制必须写进交付记录。
|
||||
7. **净化为不可绕过**:引擎档取到的 id / 路径先过既有 `sanitizeInspectResourceId` / `sanitizeInspectSourcePath` 再进载荷;宿主匹配只保留 manifest 现有 id,未知值一律丢弃(Rust 拒绝口径不变)。
|
||||
|
||||
## 已完成
|
||||
|
||||
- 桥脚本搬运 + 引擎档落地:桥脚本落到 `resources/preview/local-preview-fit.js`,`preview.rs` 改 `include_str!`,DOM 点选语义与协议不变,新增 canvas 引擎档。
|
||||
- 宿主匹配抽成纯函数 `runtimeInspectResourceMatch.ts`,`index.tsx` 改为调用它,派发载荷结构不变。
|
||||
- 脚手架与三个起步模板发布只读引擎句柄:`DEFAULT_GAME_SCRIPT_JS`、`phaser-2d-starter`、`threejs-3d-starter`、`blank-3d-scene`。
|
||||
- 自动化验证通过:五个前端用例文件(`runtimeInspectEngines` / `runtimeInspectResourceMatch` / `localGamePreviewFrame` / `resourceReferenceInput` / `appSurface`)292 passed / 9 skipped(301 项);`npm run agc:typecheck` 通过;`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --offline -- preview` 通过(43 passed)。
|
||||
- 分四个提交落盘:6c6850352(桥与引擎档)、6264fed6b(宿主匹配)、ab518ea57(脚手架与模板)、f729203c6(规范与踩坑);验收证据见里程碑文档。
|
||||
|
||||
## 阶段二进展
|
||||
|
||||
阶段一让点选能识别素材,但可用性依赖玩法满足三条约定(发布句柄、Phaser 用 Image 装载、对象标注素材身份);阶段二把这几条写进**生成期契约**,新生成的 Phaser 4 / three.js 项目不改代码即可点出素材。同一 PR 续推,`template.zip` 与线上索引仍未重新发布。
|
||||
|
||||
- 主源(唯一写全契约处):`resources/agc-skills/agc-web-game-development/SKILL.md` 新增「运行画面点选契约(运行态素材身份)」四条——发布只读引擎句柄(`{ engine:'phaser', game }` / `{ engine:'three', three: { Raycaster, Vector2, Vector3, Box3 }, scene, camera, renderer }`,three 瘦身形态见下条)、Phaser 固定 `loader: { imageLoadType: 'HTMLImageElement' }`、给素材渲染对象标注 manifest 身份(Phaser `setData('genarrativeResourceId' | 'genarrativeResourcePath', …)`,three.js `mesh.userData.…`,值只取 `agc_list_registered_assets` 的 `localAssetId` / `localPath`)、素材进 dist 走与 `localPath` 对齐的稳定路径(`public/` 原样拷贝,不用 `import` 的 hash 产物名);并写明不满足时不报错、不伪造 id、退化为不带素材的区域引用(= 验收面)。
|
||||
- 派生副本只补指针,不复制契约:`agc-web-game-development/references/game-quality-checklist.md` 增加一条勾选项;`agc-game-production-workflow/SKILL.md` 第 4 步(Game implementation)指向该契约(新游戏编排必读)。
|
||||
- 指纹同步:`npm run agc:skill-pack:sync` 更新 `resources/agc-skills/manifest.json` 的 `version` 与两个 skill 的 `sha256`;`skill_pack.rs` 逐文件 `include_bytes!`,因此**未新增 skill 文件、未改任何 Rust**。
|
||||
- 验证:`npm run agc:typecheck`(含 skill-pack 指纹校验)、`node scripts/check-doc-index.mjs`、`npm run check:encoding`、`git diff --check` 全部通过(退出码与结论行见 PR 评论)。
|
||||
- 冻结面不变:`preview.rs`、`resources/preview/local-preview-fit.js`(sha256 仍为 `6276ac48…917a`)、宿主匹配、脚手架与模板源均未改;未合并主线。
|
||||
- 可见性补强 + 句柄瘦身:桥端 three 句柄改推荐瘦身形态 `three: { Raycaster, Vector2, Vector3, Box3 }`(旧 `THREE` 命名空间仍兼容读取,缺任一构造器按未命中退化),并补 label 兜底——three `object.name` → `geometry.type` → `object.type`、Phaser `texture.key` → `name` → `type`,命中对象但取不到素材身份时芯片不再一律是 `@canvas`;契约文本、`threejs-3d-starter` 与 `blank-3d-scene` 模板同步瘦身(整命名空间实测把 517,682 B 的构建撑到 730,831 B,+41%)。
|
||||
- 该轮验证:`tests/runtimeInspectEngines.test.ts` 33 例(新增 6 例:瘦身句柄、旧命名空间兼容、name / geometry.type / object.type 兜底、缺构造器退化);真实 Chromium + 真实 three 0.184 + 真实桥脚本实测 slim 句柄命中、`Crate` / `SphereGeometry` / `LegacyMesh` 三种芯片名与对象区域矩形;`agc:typecheck`、`check-doc-index`、`check:encoding`、`git diff --check` 通过。
|
||||
- 真机复验(待执行):环境已就绪——客户端从本分支源码编译启动(`npm run agc` → `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`,Vite `127.0.0.1:3080`)、exe 内已核实内嵌新桥(6 处新代码标记:`handle.three || handle.THREE`、`inspectObjectLabel` 及其 4 个调用点)、CDP `http://127.0.0.1:9222` 可用(`Edg/154.0.4258.53`,page target 为 `陶泥儿 @ 127.0.0.1:3080`)。**点选复验待执行**:客户端登录态已过期(localStorage 里唯一 access token `exp=2026-10-01T08:24:51Z`),UI 停在登录页,需人工登录后才能打开 `gameagent-73ab2832` 进「运行」页挑选;未取得芯片文本 / 高亮矩形 / iframe 内句柄,不写"已完成"。
|
||||
@@ -0,0 +1,61 @@
|
||||
# 【里程碑】AGC 运行画面点选识别 Phaser 4 与 three.js 画面上的已登记素材
|
||||
|
||||
| 字段 | 值 |
|
||||
| ----------- | -------------------------------------------------------- |
|
||||
| Version | 1.0 |
|
||||
| Status | in-progress |
|
||||
| Date | 2026-10-04 |
|
||||
| Parent Spec | `docs/【功能说明】AGC聊天素材引用-2026-09-08.md`「运行画面素材点选」、`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` §3.4 |
|
||||
|
||||
## 目标
|
||||
|
||||
- 运行画面点选在 Phaser 4 与 three.js 玩法上,能识别点中对象用的是哪一张已登记素材,并让引用载荷带上该素材的稳定 id。
|
||||
- 识别不出来时不伪造、不报错,按主规范退化为不带素材的区域引用。
|
||||
- HTML 区域点选语义、预览消息协议、引用发送链路与 Rust 校验口径逐字不变。
|
||||
- 新建 Phaser 4 项目与 three.js 起步工程开箱即具备点选所需的引擎句柄。
|
||||
|
||||
## 范围
|
||||
|
||||
- 运行画面点选的引擎档识别:命中对象解析、素材身份来源与优先级、命中矩形高亮。
|
||||
- 宿主侧把识别结果映射成 manifest 素材 id(含"精确优先于文件名"的口径)。
|
||||
- 起步工程与新建项目默认脚手架的引擎句柄发布。
|
||||
- 上述行为的自动化证据与真机 smoke 证据。
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 生成期契约落位:把句柄发布与对象素材标注写进 AGC 玩法 Skill(**同一 PR 的阶段二**,见实施计划 `## 阶段二进展`);重新发布模板产物(`template.zip` 与线上索引)仍不在本次范围内。
|
||||
- 运行期资源使用关系持久化(不记录"某素材在某位置被用过",不注入代码、不写侧车)。
|
||||
- Cocos Creator / Godot / Unity 的点选;纯 2D canvas 引擎与 Babylon.js。
|
||||
- `runtime-region` 引用契约、预览消息版本号、Rust `resourceIds` 校验、资源选中与信息栏判据的任何变化。
|
||||
- 旧项目自动获得能力(不写迁移、不做兼容分支;限制如实写进能力说明)。
|
||||
|
||||
## 依赖与前置条件
|
||||
|
||||
- 主规范(`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`)与本节里程碑评审通过。
|
||||
- 可拉起的 AGC 真机客户端(`npm run agc`,端口与栈规约按 `genarrative-dev-stack-port-routing`),以及一份可复现的 Phaser 4 / three.js 起步工程。
|
||||
- 一张已登记到 manifest 的项目素材(图片;three.js 侧另需一张贴图)。
|
||||
- 不依赖后端服务、Provider 或登录态。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] Phaser 4 起步工程:点中由已登记素材渲染的精灵 / 图集帧,引用芯片显示该素材名,载荷 `resourceIds` 命中该素材 id。(浏览器实测:`loader.imageLoadType='HTMLImageElement'` 下自动档 `@hero` + `["hero"]`;默认 XHR 装载贴图为 blob,须由玩法显式标注,见主规范边界)
|
||||
- [x] three.js 起步工程:点中贴了已登记贴图的 Mesh(含 GLB 内嵌纹理的模型走显式标注路径),结果同上。(浏览器实测:贴图 Mesh `@hero` + `["hero"]`;`userData.genarrativeResourcePath` 档 `@enemy` + `["enemy-tag"]`)
|
||||
- [x] 同一对象同时存在显式素材标注与贴图地址推断时,取显式标注。(`tests/runtimeInspectEngines.test.ts` 33 例;浏览器重叠位取最上层且显式 id 胜出 `["enemy-tag"]`)
|
||||
- [x] 无引擎句柄 / 点中空白 / 程序化绘制三种情况:退化为不带素材的区域引用,零报错、零伪造 id,引用可插入可发送。(`runtimeInspectEngines.test.ts` 33 例;浏览器点空白 `@canvas` + `[]` 零报错)
|
||||
- [x] 命中对象时高亮为该对象在画面上的区域;取不到对象矩形时高亮范围与 DOM 档一致。(矩形换算改用 `camera.matrixCombined`;浏览器实测与期望逐值相减 Phaser 差 0px、three 最大差 ≈0.0006px,DOM 档为元素矩形)
|
||||
- [x] DOM 点选(HTML HUD、媒体 `src`、资源标识属性)行为与现有用例逐字不变。(`localGamePreviewFrame` 14、`resourceReferenceInput` 43、`appSurface` 197 passed / 9 skipped;浏览器实测 HUD 芯片名 `@HUD 区域` 未被素材名改写)
|
||||
- [x] 起步工程与新建项目默认脚手架发布引擎句柄;新建 Phaser 4 项目不额外改代码即可点选。(`DEFAULT_GAME_SCRIPT_JS` 与三个起步模板源已发布句柄;`cargo test -- scaffold` 4 passed / 1 既有 ignore。开箱即用未跑真机链路)
|
||||
|
||||
## 证据要求
|
||||
|
||||
- 自动化:引擎档识别的 JS 单元用例(jsdom 中加载真实桥脚本 + 假引擎对象)、宿主素材映射纯函数用例、桥注入与协议回归用例、`npm run agc:typecheck`。
|
||||
- 运行时:Phaser 4 与 three.js 各一条真机点选 smoke(截图或录制,含引用芯片与载荷证据)。
|
||||
- 边界:三种退化情况各一条、净化与数量上限不回归、`resourceIds` 只取 manifest 现有素材。
|
||||
|
||||
## 验收证据(2026-10-04)
|
||||
|
||||
- 命令:`npm run agc:typecheck` 通过(`check:tests:types` / `tsc` / skill-pack 指纹 / 包布局声明 / `check-config` 全过)。
|
||||
- 前端用例:`runtimeInspectEngines.test.ts`(33)+ `runtimeInspectResourceMatch.test.ts`(11)+ `localGamePreviewFrame.test.ts`(14)+ `resourceReferenceInput.test.tsx`(43)= 101 passed;`appSurface.test.ts` 197 passed / 9 skipped。
|
||||
- Rust:`cargo test … -- preview` 43 passed / 0 failed;`cargo test … -- scaffold` 4 passed / 1 既有 `#[ignore]`(需 npm 装依赖 + Provider)。
|
||||
- 真实浏览器冒烟(真实 Phaser 4.2.1 / three 0.184 + 真实桥脚本 `local-preview-fit.js`,被测 sha256 `6276ac48…917a`):已登记贴图对象得 `["hero"]`、显式标注对象得 `["enemy-tag"]`、非交互精灵经几何兜底命中;点空白 `@canvas` + `[]` 零报错;DOM 档芯片名不被改写;高亮矩形与期望逐值相减,Phaser 差 0px、three 最大差 ≈0.0006px。
|
||||
- 未覆盖:Tauri 真机链路(预览服务器注入与路由由 Rust 定向测试覆盖)、多场景叠加、非默认相机(scroll/zoom/rotation)、高亮真机目视。
|
||||
@@ -49,3 +49,10 @@
|
||||
- 自动化:admin-web 详情/动作测试、api-server 预览会话和权限测试、DTO/类型检查、编码和 diff 检查。
|
||||
- 运行时:本地管理员打开待审版本,确认资料、封面/截图和待审包试玩;确认旧公开版本不会被误播。
|
||||
- 边界:过期 Token、错误 versionId、Cookie、越权管理员、缺入口、资源 404、CAS 冲突和幂等重放。
|
||||
|
||||
## 本轮核对(2026-10-05,试玩加载体验与超时收口)
|
||||
|
||||
- 「试玩当前待审版本」原先只渲染 `<iframe class="admin-game-review-preview">`:加载期是一块 `min-height: 42rem` 的空白,没有加载文案、没有超时、没有失败提示(会话过期或资源 404 会永远停在空白)。2026-10-05 起与网页游玩页共用共享加载面 `PlatformGameLoadingSurface`(`packages/shared`):加载期显示游戏标题/封面/动效进度与阶段文案,iframe `onLoad` 后才让位给画面。
|
||||
- 加载上限与网页游玩页统一为 `PLATFORM_GAME_LOADING_TIMEOUT_MS`(20 秒):超时后加载面换成「试玩版本加载超时」并给出「重新创建试玩会话」入口;新建会话会重置加载态,不会沿用上一个会话的 ready。
|
||||
- 用例:`apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx` 新增两条(加载面在画面载入后让位、超过上限后给出超时说明与重建入口,重建后新会话 URL 生效且回到加载态)。
|
||||
- 仍未变化:预览会话 token 与 `no-store`、`sandbox="allow-scripts"`、Cookie 拒绝、版本绑定与审核 CAS 口径都不动。
|
||||
|
||||
@@ -333,3 +333,9 @@
|
||||
- **详情页返回按来源回到上一页**:`GameDetailPage` 左上角原来是写死的「返回游戏广场」,并且直接 `setSelectionStage('games')`(等于 push 一条新的 `/games`)。从「我的游戏」点进详情再返回就落到广场,从广场进详情返回也会多压一条重复历史。现在文案简化为「返回」,行为改成优先 `window.history.back()`,由 `ActiveApp` 已有的 `popstate` 同步把舞台还原成真正的来源页(我的游戏 / 广场);游玩页的返回同样处理,避免详情↔游玩互相 push。协议侧给应用写入的历史条目补了 `__genarrativeAppHistoryDepth`,新增 `hasAppHistoryBackEntry()` 判断「当前条目是应用内导航写入的且存在上一页」;直接打开详情深链、或原生壳里没有可回退条目时,才 `replaceAppHistoryPath` 兜底到广场(游玩页兜底到自己的详情)。回归用例 `activeAppPageRoutes.test.ts` 锁定深度标记语义,`PlatformEntryActiveFlowShell.test.tsx`「游戏详情返回」两条锁定原生返回与深链兜底。
|
||||
- **游戏分发的返回文案统一成「返回」**:`GameDetailPage`、`GamePlayPage`(含超时面板里的按钮)、`MyGamesPage`、`GamePublishPage` 四处原本是「返回游戏广场 / 返回详情」,同一套流程里出现三种写法;现在统一成「返回」,目标页仍由各自的 `onBack`(广场 / 我的游戏 / 详情)决定,按钮不再承诺一个可能不对的目的地。`GameDistributionPages.test.tsx` 的断言同步改为 `/^返回$/u`。游玩页工具栏上那个按钮展示的是游戏名(退出开始面板后就靠它认游戏),不属于「返回 xx」文案,保持不动。
|
||||
- 验证:`npx vitest run src/components/game-distribution/GameDistributionPages.test.tsx`(21 passed)、`npx vitest run src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx`(22 passed)、`npx vitest run src/routing/activeAppPageRoutes.test.ts`、`npm run typecheck`、`npx eslint`(五个改动文件)、`npm run check:encoding`、`git diff --check` 全部通过;启动面板的溢出可达性、「我的游戏」移动端横向溢出都用真实 Chromium 静态夹具量过(见 pitfalls 同日条目),详情返回链在真实 Chromium(dev 栈 390x844)实测 `/games`→详情→返回=`/games`、深链直开详情→返回=`/games`、详情→立即玩→返回详情=`/games/detail?id=…`。视觉与真机横竖屏走查仍需人工/截图评审(沿用阶段 C 第 8 条未取证口径)。
|
||||
|
||||
## 本轮核对(2026-10-05,游玩页加载体验与发行资源传输)
|
||||
|
||||
- 游玩页此前在 `onLoad` 前只盖一行 `游戏正在启动…`(近黑壳上),用户反馈「一直黑屏」。现在加载期显示共享加载面 `PlatformGameLoadingSurface`(`packages/shared`,封面/标题 + 动效进度 + 按时长推进的阶段文案),`onLoad` 后淡入画面;超时面板与重试/返回不变,上限仍与后台试玩统一为 `PLATFORM_GAME_LOADING_TIMEOUT_MS`(20 秒)。
|
||||
- 发行网关(`api-server`)对文本类发行资源下发 `Content-Encoding: gzip` + `Vary: Accept-Encoding`,并下发强 `ETag`:`If-None-Match` 命中返回 304。`max-age=60, must-revalidate` 与撤销窗口不变,差别只是 60 秒之后重复游玩不再重下整包。后台试玩会话仍是 `no-store` 且无 ETag。
|
||||
- 证据:真实栈 4 Mbps/100 ms 模拟链路对照(改前 3298 ms / 4587 ms 才出画面,且期间无有效加载反馈),发行网关字节数对照见 PR;`cargo test -p api-server --bin api-server -- game_distribution` 与三条前端用例(共享加载面、游玩页、后台试玩)为回归门禁。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user