diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/useElementFullscreen.ts b/apps/ai-game-creator-shell/src/features/project-workspace/useElementFullscreen.ts new file mode 100644 index 000000000..2235ebb5d --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/useElementFullscreen.ts @@ -0,0 +1,75 @@ +import { + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +export type ElementFullscreenController = { + /** 要全屏的那一格;挂在它的 `ref` 上。 */ + ref: RefObject; + isFullscreen: boolean; + isSupported: boolean; + toggleFullscreen: () => void; +}; + +/** + * 运行画面「全屏预览」用的**元素级**全屏。 + * + * 只走标准 Fullscreen API:宿主是浏览器还是客户端 WebView 都是同一份实现。**不接 Tauri 的 + * 窗口级全屏**——那是把整块工作台(连对话栏)放大,语义是「全屏应用」,不是「全屏预览画面」。 + * + * 支持判据是两层:`requestFullscreen` 真的在,且没有被宿主显式关掉 + * (`fullscreenEnabled === false`,例如被权限策略挡住)。任一不成立就不渲染这枚按钮—— + * 一枚点了没反应的全屏按钮比没有按钮更糟。 + * + * 全屏元素被移除时浏览器按规范自己退出全屏(切回资源页签、切项目都走这条),所以这里不为 + * 卸载补退出逻辑;按钮态只认 `fullscreenchange`,Esc 与宿主自己退出都会回落。 + */ +export function useElementFullscreen< + T extends HTMLElement, +>(): ElementFullscreenController { + const elementRef = useRef(null); + const [isFullscreen, setIsFullscreen] = useState(false); + const isSupported = + typeof document !== 'undefined' && + document.fullscreenEnabled !== false && + typeof document.documentElement?.requestFullscreen === 'function'; + + useEffect(() => { + if (typeof document === 'undefined') { + return undefined; + } + // 事件挂在全局 `document`、在回调里读 ref:运行画面是条件挂载的,按 ref 订监听会在 + // 「进运行页签之前」就订不上,退出全屏(Esc、F11、宿主)再也收不回来。 + const sync = () => { + const element = elementRef.current; + setIsFullscreen( + element !== null && document.fullscreenElement === element, + ); + }; + document.addEventListener('fullscreenchange', sync); + sync(); + return () => document.removeEventListener('fullscreenchange', sync); + }, []); + + const toggleFullscreen = useCallback(() => { + const element = elementRef.current; + if (!element) { + return; + } + const ownerDocument = element.ownerDocument; + // 已经有人在全屏(本元素,或页面里别的东西)时这一步只负责退出:退出本身幂等, + // 不需要先判断当前全屏的是不是自己。 + if (ownerDocument.fullscreenElement) { + void ownerDocument.exitFullscreen?.()?.catch(() => {}); + return; + } + // 失败(用户手势丢失、权限策略拒绝)不改按钮状态,界面回到「还是没全屏」的原样; + // 拒绝的 Promise 必须接住,否则会冒成未处理拒绝。 + void element.requestFullscreen?.()?.catch(() => {}); + }, []); + + return { ref: elementRef, isFullscreen, isSupported, toggleFullscreen }; +} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 75c0b2d0e..a598a836b 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -8566,6 +8566,43 @@ iframe.preview-frame { min-height: 0; } +/* + * 画面右下角的「全屏预览」。压在游戏画面上,所以用深色半透明底 + 白图标:任何游戏配色下都 + * 看得清,也不在画面中间抢位置。全屏那一格还是它自己(`:fullscreen` 铺满屏幕),所以这枚按钮 + * 在全屏里照旧可用,用户点它就能退出来。 + */ +.game-run-preview-fullscreen { + position: absolute; + right: 10px; + bottom: 10px; + z-index: 2; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid rgb(255 255 255 / 28%); + border-radius: 10px; + background: rgb(12 14 20 / 62%); + color: #fff; + cursor: pointer; + backdrop-filter: blur(6px); +} + +.game-run-preview-fullscreen:hover, +.game-run-preview-fullscreen:focus-visible { + background: rgb(12 14 20 / 84%); + outline: 0; +} + +/* 全屏里这一格就是整块屏幕:舞台自己的虚线边框与圆角让位给画面。 */ +.game-run-preview:fullscreen { + min-height: 0; + border: 0; + border-radius: 0; + background: #05070b; +} + .game-run-preview-empty { display: grid; align-content: center; @@ -8588,13 +8625,61 @@ iframe.preview-frame { overflow-wrap: anywhere; } +/* + * 运行页签的底部信息栏。没有内容时整栏不渲染(判据在 `project-development/index.tsx`), + * 收起态只剩上面那一行开合按钮——条目卡片的 156px 最小高度不会再变成一片空白色块。 + * + * 卡片只在**有内容**时渲染:一栏也铺满整行,不留半张空位。原先「数值微调」那一栏没有登记表 + * (前端没有数据源),按用户口径没有功能就先不渲染,它的字段样式(label / input)随这一栏一起 + * 删掉;登记表接进来时样式与卡片一起回来。 + */ .game-run-panels { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.game-run-panels-controls { + display: flex; + justify-content: flex-end; +} + +.game-run-panels-toggle { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 26px; + padding: 0 10px; + border: 1px solid #eadbd4; + border-radius: 999px; + background: #fff; + color: #62483d; + cursor: pointer; + font-size: 11px; + font-weight: 700; +} + +.game-run-panels-toggle:hover, +.game-run-panels-toggle:focus-visible { + border-color: #dfb59f; + background: #fdf1ea; + outline: 0; +} + +.game-run-panels-toggle-chevron { + transition: transform 120ms ease; +} + +.game-run-panels-toggle-chevron.is-collapsed { + transform: rotate(-90deg); +} + +.game-run-panels-body { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 10px; } -.game-run-panels > section { +.game-run-panels-body > section { display: grid; align-content: start; gap: 8px; @@ -8616,7 +8701,6 @@ iframe.preview-frame { } .game-run-panels p, -.game-run-panels label, .game-run-panels dt, .game-run-panels dd { margin: 0; @@ -8644,24 +8728,6 @@ iframe.preview-frame { overflow-wrap: anywhere; } -.game-run-panels label { - display: grid; - grid-template-columns: minmax(0, 1fr) 88px; - align-items: center; - gap: 8px; -} - -.game-run-panels input { - min-width: 0; - height: 30px; - padding: 0 8px; - border: 1px solid #eadbd4; - border-radius: 8px; - background: #faf7f5; - color: #8f7d75; - font-size: 10px; -} - .game-workbench-chat { display: grid; grid-template-rows: auto minmax(0, 1fr); @@ -10006,7 +10072,7 @@ iframe.preview-frame { min-width: 460px; } - .game-run-panels { + .game-run-panels-body { grid-template-columns: 1fr; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 5aea932d5..4117d42ba 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -20,6 +20,7 @@ import { openUrl } from '@tauri-apps/plugin-opener'; import { AtSign, Box, + ChevronDown, Crosshair, ExternalLink, Eye, @@ -34,6 +35,7 @@ import { LayoutGrid, ListFilter, Maximize2, + Minimize2, Minus, Music2, PackageOpen, @@ -141,6 +143,7 @@ import { resourceLabelResolver, resourceReferenceCategoryLabel, } from '../../features/project-workspace/resourceReferences'; +import { useElementFullscreen } from '../../features/project-workspace/useElementFullscreen'; import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; import { type ResourceCanvasAssetGenerationPanelDraft, @@ -1818,6 +1821,9 @@ export default function ProjectDevelopmentView({ }: ProjectDevelopmentViewProps) { const [mode, setMode] = useState('resources'); const [runtimeInspectMode, setRuntimeInspectMode] = useState(false); + // 运行画面的「全屏预览」:全屏的是画面那一格(`.game-run-preview`),不是整块工作台, + // 所以按钮与 ref 都归运行表现层自己持有。 + const runPreviewFullscreen = useElementFullscreen(); const [resourceBookState, dispatchResourceBook] = useReducer( resourceBookReducer, initialResourceBookState, @@ -3000,6 +3006,23 @@ export default function ProjectDevelopmentView({ const selectedResource = canvasResources.find((resource) => resource.id === selectedResourceId) ?? null; + /** + * 运行页签底部信息栏(`.game-run-panels`)的开合。 + * + * 只有一条判据:**有没有内容**——当前是「运行页里选中了一张资源」,「信息展示」渲染的是它 + * 的只读字段。内容从无到有 / 从有到无都自动跟上(没有内容就自动收起),用户在同一段内容里 + * 手动收 / 展则一直有效,不会被别的渲染重开。 + * + * 「数值微调」暂时没有登记表(前端没有数据源),按用户口径没有功能就先不渲染这一栏;等 + * 后端编辑态登记表接进来后,它与它的内容一起进这个判据。 + */ + const runPanelsHaveContent = selectedResource !== null; + const [runPanelsExpanded, setRunPanelsExpanded] = + useState(runPanelsHaveContent); + const runPanelsBodyId = useId(); + useEffect(() => { + setRunPanelsExpanded(runPanelsHaveContent); + }, [runPanelsHaveContent]); /** * 画布选中工具栏「编辑标签」的入口判定: * - 选中 1 项:既有的单素材标签编辑(增删标签行为不变); @@ -11002,7 +11025,7 @@ export default function ProjectDevelopmentView({ ) : (
-
+
{embeddedPreviewUrl ? ( 点击顶部播放按钮后将在这里直接运行游戏
)} + {/* + 全屏预览:贴在画面右下角。**只有画面这一格进全屏**——顶部页签、右侧对话与 + 底部信息栏都不跟着放大,符合「预览画面」而不是「全屏应用」。没有活预览时不渲染, + 宿主没有 Fullscreen API 时也不渲染(见 `useElementFullscreen`)。 + */} + {embeddedPreviewUrl && runPreviewFullscreen.isSupported ? ( + + ) : null}
-
-
-
-
- {selectedResource ? ( - + {/* + 底部信息栏:有内容才存在,没有内容就自动收起(整栏不渲染)。 + 收起态只留下这一行开合按钮,展开态才渲染条目卡片——卡片的 156px 最小高度 + 因此不会再变成一片空白色块。 + */} + {runPanelsHaveContent ? ( +
+
+ +
+ {runPanelsExpanded ? ( +
+
+
+
+ {selectedResource ? ( + + ) : null} +
+
) : null} -
-
-
-
-
-
+ + ) : null}
)} {/* diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 1d30225a8..ac6c2852c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -3886,6 +3886,11 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(runTab); const runInfoPanel = screen.getByLabelText('资源信息面板'); expect(resourceInfoFieldRows(runInfoPanel)).toEqual(expectedRows); + // 有内容就自动展开;手动收起后条目卡片整体让位,只剩那一行开合按钮。 + fireEvent.click(screen.getByRole('button', { name: '收起信息栏' })); + expect(screen.queryByLabelText('资源信息面板')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '展开信息栏' })); + expect(screen.getByLabelText('资源信息面板')).not.toBeNull(); // 画布浮层只属于画布:切到运行视图后不再渲染。 expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull(); }); @@ -6456,8 +6461,11 @@ export function registerProjectWorkbenchFoundationTests() { 'allow-scripts allow-same-origin allow-forms allow-pointer-lock', ); expect(screen.queryByLabelText('测试切片控件')).toBeNull(); - expect(screen.getByLabelText('资源信息面板')).not.toBeNull(); - expect(screen.getByLabelText('数值微调面板')).not.toBeNull(); + // 底部信息栏没有内容就整栏不渲染:此时既没有选中资源(信息展示没有字段),也没有 + // 已登记的微调项。留着两张空卡片正是验收现场那半条「空信息栏白占一块高度」。 + expect(screen.queryByLabelText('资源信息面板')).toBeNull(); + expect(screen.queryByLabelText('数值微调面板')).toBeNull(); + expect(screen.queryByRole('button', { name: '展开信息栏' })).toBeNull(); fireEvent.click(screen.getByRole('tab', { name: '资源管理' })); fireEvent.click(screen.getByRole('button', { name: '按类型' })); diff --git a/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx b/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx new file mode 100644 index 000000000..2b289d564 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx @@ -0,0 +1,142 @@ +/** @vitest-environment jsdom */ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import ProjectDevelopmentView from '../src/view/project-development'; + +const PREVIEW_URL = 'http://127.0.0.1:4173/'; + +function installInvoke() { + window.__TAURI__ = { + core: { + invoke: vi.fn(async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return { + resourceIds: [], + referenceEdges: [], + taskFlows: [], + categories: [], + diagnostics: [], + }; + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + throw new Error(`unexpected invoke ${command}`); + }), + }, + } as unknown as typeof window.__TAURI__; +} + +function renderRunView() { + installInvoke(); + const manifest = createGameCreationAppManifest( + 'run-preview-fullscreen', + '运行页全屏预览', + ); + render( + 项目总控} + onHomeOpen={vi.fn()} + onProjectsOpen={vi.fn()} + onNotice={vi.fn()} + />, + ); +} + +/** + * jsdom 完全没有 Fullscreen API(`document.fullscreenEnabled` / `requestFullscreen` / + * `fullscreenElement` 都是 undefined),所以这一组用例必须自己把宿主那一份补出来: + * 全屏状态、元素身份与 `fullscreenchange` 都按规范最小实现,只用于验按钮的真实行为。 + */ +function installFullscreenHost() { + let fullscreenElement: Element | null = null; + const requestFullscreen = vi.fn(function (this: Element) { + // 这枚桩就是要记录调用方传进来的 `this`:全屏元素身份正是本用例的断言对象(画面那一格)。 + // eslint-disable-next-line @typescript-eslint/no-this-alias + fullscreenElement = this; + document.dispatchEvent(new Event('fullscreenchange')); + return Promise.resolve(); + }); + const exitFullscreen = vi.fn(() => { + fullscreenElement = null; + document.dispatchEvent(new Event('fullscreenchange')); + return Promise.resolve(); + }); + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get: () => fullscreenElement, + }); + Object.defineProperty(document, 'exitFullscreen', { + configurable: true, + value: exitFullscreen, + }); + Object.defineProperty(Element.prototype, 'requestFullscreen', { + configurable: true, + writable: true, + value: requestFullscreen, + }); + return { requestFullscreen, exitFullscreen }; +} + +afterEach(() => { + document.body.innerHTML = ''; + Reflect.deleteProperty(document, 'fullscreenElement'); + Reflect.deleteProperty(document, 'exitFullscreen'); + Reflect.deleteProperty(Element.prototype, 'requestFullscreen'); + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe('运行页「全屏预览」', () => { + it('点画面右下角那枚按钮就把画面那一格送进全屏,再点退出', async () => { + const { requestFullscreen, exitFullscreen } = installFullscreenHost(); + renderRunView(); + + const button = await screen.findByRole('button', { name: '全屏预览' }); + // 按钮住在画面那一格里(右下角由样式给),全屏的也是那一格——不是整块工作台。 + const stage = document.querySelector('.game-run-preview'); + expect(stage).not.toBeNull(); + expect(button.closest('.game-run-preview')).toBe(stage); + expect(button.getAttribute('aria-pressed')).toBe('false'); + + fireEvent.click(button); + expect(requestFullscreen).toHaveBeenCalledTimes(1); + // 全屏元素就是画面那一格:按钮自己也算得出来(状态来自 fullscreenchange,不是乐观值)。 + expect(document.fullscreenElement).toBe(stage); + const exitButton = await screen.findByRole('button', { + name: '退出全屏预览', + }); + expect(exitButton.getAttribute('aria-pressed')).toBe('true'); + + fireEvent.click(exitButton); + expect(exitFullscreen).toHaveBeenCalledTimes(1); + expect( + (await screen.findByRole('button', { name: '全屏预览' })).getAttribute( + 'aria-pressed', + ), + ).toBe('false'); + }); + + it('宿主没有 Fullscreen API 时不渲染这枚按钮,而不是留一个点了没反应的入口', async () => { + renderRunView(); + + await screen.findByTitle('运行页全屏预览 游戏运行画面'); + expect(screen.queryByRole('button', { name: '全屏预览' })).toBeNull(); + }); +}); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index cdf6b4e73..a0eeccd0e 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -75,7 +75,8 @@ - 运行视窗必须占满中央工作区为游戏保留的可用区域。loopback 预览页通过客户端本地 preview server 注入的只读尺寸桥上报文档实际宽高;宿主只接受当前 iframe、当前 loopback origin 的固定版本消息,并将完整游戏文档等比缩放、居中放入视窗。iframe 首次适配后发生的真实内容增高或缩短仍必须被接受;仅浏览上下文宽高回灌或内容宽高未变化时保持当前状态,不触发重复渲染。 - 窗口或中央区域尺寸变化后必须重新测量和适配;内容已经放得下时保持 `1:1`,不得无故放大。游戏文档宽高超过视窗时缩小整体画面,不显示 iframe 横向或纵向滚动条,也不得用单纯裁切替代完整展示。尺寸桥以根布局 `ResizeObserver` 为主,并在页面可见时每 `500ms` 至多探测 `512` 个元素作为绝对定位溢出的低频兜底;探测截断时不得用部分样本下调尺寸,viewport 耦合的 `100vh / 100% / bottom / right` 布局也不得形成自反馈。相同测量结果去重,不监听整页属性、文本或子节点突变;桥不读取项目正文、不修改 manifest、游戏文件或运行业务状态。桥脚本只能注入到真实 HTML 标签上下文,不能把脚本、样式、模板或注释中的 `` / `` 文本误判为结束标签;省略结束标签的 UTF-8 HTML 仍需安全注入。 -- 运行视窗下方继续保留“信息展示”和“数值微调”区域标题及原有面板高度;没有真实资源信息或已登记微调项时,内容区域保持空白,不显示示例字段、默认数值、未载入控件或功能说明,也不得因内容为空压缩两个面板。Agent 对话标题栏不显示头像图标,“与陶泥儿的对话”及副标题按标题栏左侧对齐,钱包和审批入口继续位于右侧。 +- 运行视窗右下角提供“全屏预览”:只把游戏画面那一格送进全屏,顶部页签、右侧对话和底部信息栏不跟着放大;再次点击该入口、按 `Esc` 或由宿主退出全屏都回到原布局。宿主没有 Fullscreen API 时整枚入口不渲染,不留点了没反应的按钮。 +- 运行视窗下方的信息栏只在**有真实内容**时存在(当前判据是运行页里选中了资源,渲染它的只读字段):没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;不显示示例字段、默认数值、未载入控件或功能说明。暂时没有数据源的区域(「数值微调」的登记表)不渲染区域标题与卡片,等编辑态登记表接进来后与内容一起出现。Agent 对话标题栏不显示头像图标,“与陶泥儿的对话”及副标题按标题栏左侧对齐,钱包和审批入口继续位于右侧。 - 数值修改立即写入当前项目的编辑态配置。 - 当前已拉起的体验预览和测试切片不热更新;必须重新拉起后才能消费新值。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f1b42991f..3dc97ef45 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-09-23 运行视窗:右下角全屏预览 + 没有内容就自动收起的信息栏 + +- 背景:运行页右下角缺一个把游戏画面放大到整屏的入口;运行视窗下方常驻「信息展示 / 数值微调」两张卡片,没有选中资源时就是两块空白,验收现场提出「没有功能就暂时隐藏」。 +- 决策一:新增 `useElementFullscreen`(`apps/ai-game-creator-shell/src/features/project-workspace/`),用标准**元素级** Fullscreen API 把**画面那一格**(`.game-run-preview`)送进全屏——不接 Tauri 窗口级全屏,那是把整块工作台连对话栏一起放大的「全屏应用」,不是「全屏预览画面」。入口贴在画面右下角,全屏后仍在原位可点退出;按钮态只认 `fullscreenchange`,Esc、宿主退出都会回落。`requestFullscreen` 不存在或被 `fullscreenEnabled === false` 关掉时整枚入口不渲染,不留点了没反应的按钮。 +- 决策二:`.game-run-panels` 改成「有内容才存在」的可收起信息栏。判据只有「有没有内容」(当前 = 运行页里选中了资源,信息展示渲染它的只读字段):内容从无到有自动展开、从有到无自动收起,同一段内容里用户手动收 / 展不被别的渲染重开。收起态只剩一行「收起信息栏 / 展开信息栏」按钮,条目卡片的 `156px` 最小高度不再变成空白色块;只有一栏内容时卡片铺满整行。 +- 决策三:「数值微调」暂时没有登记表(前端没有数据源),按用户口径在没有功能时先不渲染它的区域标题与卡片,对应 `label / input` 声明一并删除;登记表接进来时与内容一起回归。这一条覆盖 PRD §3.4 原先「保留两个面板标题、不得因空内容压缩」的口径,PRD 与技术方案已同步改写。 +- 验证:新增 `tests/runPreviewFullscreen.test.tsx`(补出 jsdom 缺失的 Fullscreen API:按钮住在画面那一格里、点击 → `requestFullscreen` → 退出全屏,以及宿主没有该 API 时不渲染);AGC 子集补「信息栏有内容自动展开 / 手动收起 / 再展开」,并把「没有内容时运行页仍渲染两张卡片」的旧断言改成整栏不渲染。`apps/ai-game-creator-shell:check:web` 全量通过(`tsc` + 1924 项)、编码检查与 `git diff --check` 通过;并用真实 Chromium 对工作台冒烟:右下角按钮只把画面那一格送进全屏且可退出、选中资源后信息栏自动展开、收起后画面变高、清空选中后整栏消失。 + ## 2026-09-23 AGC 发布前先守可运行原型门禁 - 背景:客户端已经显示“首个可运行原型尚未完成,运行视图暂不可用”,但发布入口仍会先执行用户项目的 `build`,导致未完成原型也进入构建并在后续失败。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 10b7897e9..ee894dd0a 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -974,7 +974,7 @@ game-project/ - 中间主视窗提供 `resource-overview / resource-editor / ui-editor / run` 四种状态。2026-08-10 起普通用户“新增资源”显示为禁用态且处理函数拒绝 create;所有现役资源从聚焦态“编辑资源”进入非破坏性派生:静态图片走 `derive + editKind='image-reference'`,SVG、视频、音频、文档/代码、Agent 回执和项目版本进入统一资源编辑壳并按能力分流。编辑面板只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。 - 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执、已导入附件和已完成任务明确登记的产物派生资源,固定按 manifest 资产功能分类 `category`(`UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类`)加末尾独立的「项目版本」栏目分区;未知任务产物不再兜底为版本,未完成任务或未在 `artifacts` 中登记的任意本地音频也不冒充正式资源。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。 - 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源总览卡片拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供通用工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态。2026-08-30 视觉验收修正:资源总览所有栏目初次适配与复位最多以 `1.5` 倍缩放卡片,避免单个低尺寸卡片被插值放大成糊图;用户主动缩放仍沿用通用画布倍率,并按“排序模式 + 栏目”保留当前会话内的平移和缩放。美术资源聚焦态改为视口级大预览,保留原始资源读取与元数据,不生成第二份缩略图,图片 / 视频预览按弹窗可用高度展示并允许正文滚动。该资源总览边界不限制后续素材创作无限画布内的图片图层移动/缩放、生成和正式回写。 -- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并保留素材信息和数值微调面板;两个面板保持原有 `156px` 最小高度,没有真实数据时只让正文为空,不渲染预设字段、默认数值、未载入控件或自然语言功能占位,也不随空内容收缩。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `` / ``。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;宿主单独记录最近一次合法上报的 iframe viewport,首次收到由自身 fit 切换产生的新 viewport 测量时只确认该 viewport、不反向改写内容尺寸,待 viewport 稳定后仍接受真实内容宽高变化,从而阻断 `100vh` / 百分比布局在两个适配尺寸之间回灌振荡。重复内容尺寸不更新 React 状态,陈旧 viewport 消息继续忽略。容器 resize 期间保留内容尺寸与已观察 viewport,只按新容器尺寸连续重算缩放,避免拖动窗口时在原生尺寸和 fit 之间闪烁;preview URL 变化时才清空状态并重新测量。放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。 +- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,画面右下角提供“全屏预览”(元素级 Fullscreen API,只把画面那一格送进全屏;宿主没有该 API 时整枚入口不渲染)。其下的信息栏只在有真实内容(运行页选中资源,渲染只读素材信息)时存在:没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;「数值微调」的登记表尚未接入,因此暂时不渲染它的区域标题与卡片,不渲染预设字段、默认数值、未载入控件或自然语言功能占位。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `` / ``。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;宿主单独记录最近一次合法上报的 iframe viewport,首次收到由自身 fit 切换产生的新 viewport 测量时只确认该 viewport、不反向改写内容尺寸,待 viewport 稳定后仍接受真实内容宽高变化,从而阻断 `100vh` / 百分比布局在两个适配尺寸之间回灌振荡。重复内容尺寸不更新 React 状态,陈旧 viewport 消息继续忽略。容器 resize 期间保留内容尺寸与已观察 viewport,只按新容器尺寸连续重算缩放,避免拖动窗口时在原生尺寸和 fit 之间闪烁;preview URL 变化时才清空状态并重新测量。放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。 - 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。 - 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。 - 当前 run 专业状态与项目历史成果分离:状态继续严格匹配当前 `parentRunId`;已有文本成果从专业 Agent 持久对话中合法的 `agent-finalization-<32 lower hex>` assistant 恢复,并以“历史成果”来源投影到资源管理文档区。新 run 失败、待确认、候选为空或持久对话瞬时读取失败不得清除已恢复的旧成功回执,普通失败 assistant 也不得被当作成果。