From 6c6850352efd8a4bdc29869705693fc37342ad48 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 20:45:48 +0800 Subject: [PATCH 01/14] =?UTF-8?q?AGC=20=E8=BF=90=E8=A1=8C=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E7=82=B9=E9=80=89=E6=96=B0=E5=A2=9E=20Phaser=204=20?= =?UTF-8?q?=E4=B8=8E=20three.js=20=E5=BC=95=E6=93=8E=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 预览桥脚本从 preview.rs 的 Rust 字符串常量搬到 resources/preview/local-preview-fit.js,改由 include_str! 引用,尺寸上报与 DOM 点选行为逐字不变 - 抽出共享命中解析,DOM 档语义保持原样 - 新增 canvas 引擎档:只认玩法发布的只读句柄,实现 Phaser 4 与 three.js 两条适配与几何兜底 - 素材身份显式标注优先于贴图地址推断,取不到时退化为不带素材的区域引用,不伪造 id - 新增引擎档用例,直接加载随包桥脚本文件断言消息载荷 - 随包桥脚本加入 prettier 排除,保持逐字内容 --- .prettierignore | 2 + .../resources/preview/local-preview-fit.js | 1074 ++++++++++++++ .../src-tauri/src/preview.rs | 509 +------ .../tests/runtimeInspectEngines.test.ts | 1277 +++++++++++++++++ 4 files changed, 2354 insertions(+), 508 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js create mode 100644 apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts diff --git a/.prettierignore b/.prettierignore index a32676e37..5a4798841 100644 --- a/.prettierignore +++ b/.prettierignore @@ -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.* diff --git a/apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js b/apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js new file mode 100644 index 000000000..ade83b6c9 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js @@ -0,0 +1,1074 @@ +(() => { + const messageType = 'genarrative.local-preview-size.v1'; + const maxContentProbes = 512; + let ready = document.readyState === 'complete'; + let frame = 0; + let lastPublishedSize = ''; + const elementLayoutSamples = { width: new WeakMap(), height: new WeakMap() }; + const previousRootLayoutSample = {}; + const previousBodyLayoutSample = {}; + const rootIndependentLayout = { width: false, height: false }; + const contentResolutionState = { width: { axis: 'width' }, height: { axis: 'height' } }; + const resetLayoutCouplingSamples = (axis) => { + elementLayoutSamples[axis] = new WeakMap(); + previousRootLayoutSample[axis] = undefined; + previousBodyLayoutSample[axis] = undefined; + }; + const resolveViewportCoupling = ( + extent, viewport, previousExtent, previousViewport, previousCoupled, + ) => { + if (extent === undefined || previousExtent === undefined || previousViewport === undefined) { + return false; + } + const viewportDelta = viewport - previousViewport; + if (Math.abs(viewportDelta) < 1) { + return Math.abs(extent - previousExtent) < 1 ? previousCoupled : false; + } + const extentDelta = extent - previousExtent; + return Math.abs(extentDelta) >= 1 && extentDelta * viewportDelta > 0; + }; + const previewStyleExtent = (value) => { + const extent = Number.parseFloat(value || ''); + return Number.isFinite(extent) ? extent : undefined; + }; + const previewTypedStyleValue = (element, property) => { + try { + return element?.computedStyleMap?.().get(property)?.toString().trim().toLowerCase(); + } catch { + return undefined; + } + }; + const previewTypedMinimumCreatesExtent = (value) => { + if (value === undefined || value === 'auto' || value === 'none') return false; + const extent = Number.parseFloat(value); + return Number.isFinite(extent) ? extent > 0 : true; + }; + const rootAxisMirrorsBody = (axis, root, body, rootExtent, bodyExtent) => { + if (!root || !body) return false; + const sameExtent = Math.abs(rootExtent - bodyExtent) <= 1; + const sizeProperty = axis === 'width' ? 'width' : 'height'; + const minimumProperty = axis === 'width' ? 'min-width' : 'min-height'; + const typedSize = previewTypedStyleValue(root, sizeProperty); + const typedMinimum = previewTypedStyleValue(root, minimumProperty); + if (typedSize !== undefined || typedMinimum !== undefined) { + rootIndependentLayout[axis] = (typedSize !== undefined && typedSize !== 'auto') + || previewTypedMinimumCreatesExtent(typedMinimum); + } else if (!sameExtent) { + rootIndependentLayout[axis] = true; + } + return sameExtent && !rootIndependentLayout[axis]; + }; + const previewStyleUsesViewport = (value) => ( + (value || '').includes('%') + || /\b(?:v[wh]|vmin|vmax|sv[wh]|lv[wh]|dv[wh]|vi|vb|svi|svb|lvi|lvb|dvi|dvb)\b/i.test(value || '') + ); + const previewViewportStyleBaseline = (value, viewport) => { + const normalized = (value || '').trim().toLowerCase(); + const amount = Number.parseFloat(normalized); + if (!Number.isFinite(amount) || normalized.includes('calc(')) return undefined; + if (normalized.endsWith('%')) return viewport * amount / 100; + if (/^-?\d*\.?\d+(?:d?v[wh]|s?v[wh]|l?v[wh])$/.test(normalized)) { + return viewport * amount / 100; + } + return undefined; + }; + const sampleAxisViewportCoupling = ( + edge, sizeStyle, minimumStyle, viewport, previous, + ) => { + const size = previewStyleExtent(sizeStyle); + const minimum = previewStyleExtent(minimumStyle); + const sizeUsesViewport = previewStyleUsesViewport(sizeStyle); + const minimumUsesViewport = previewStyleUsesViewport(minimumStyle); + const edgeCoupled = resolveViewportCoupling( + edge, viewport, previous?.edge, previous?.viewport, previous?.edgeCoupled || false, + ); + const sizeCoupled = sizeUsesViewport || resolveViewportCoupling( + size, viewport, previous?.size, previous?.viewport, + previous?.sizeUsesViewport && !sizeUsesViewport ? false : previous?.sizeCoupled || false, + ); + const minimumCoupled = minimumUsesViewport || resolveViewportCoupling( + minimum, viewport, previous?.minimum, previous?.viewport, + previous?.minimumUsesViewport && !minimumUsesViewport + ? false + : previous?.minimumCoupled || false, + ); + const edgeBaseline = edgeCoupled + ? previous?.edgeBaseline ?? previous?.baseline ?? previous?.edge ?? edge + : undefined; + const sizeBaseline = sizeCoupled + ? previous?.sizeBaseline + ?? previous?.baseline + ?? previous?.size + ?? previewViewportStyleBaseline(sizeStyle, viewport) + ?? size + : undefined; + const minimumBaseline = minimumCoupled + ? previous?.minimumBaseline + ?? previous?.baseline + ?? previous?.minimum + ?? previewViewportStyleBaseline(minimumStyle, viewport) + ?? minimum + : undefined; + const baseline = Math.max( + 0, edgeBaseline || 0, sizeBaseline || 0, minimumBaseline || 0, + ) || undefined; + return { + edge, + size, + minimum, + viewport, + edgeCoupled, + sizeCoupled, + minimumCoupled, + edgeBaseline, + sizeBaseline, + minimumBaseline, + sizeUsesViewport, + minimumUsesViewport, + baseline, + coupled: edgeCoupled || sizeCoupled || minimumCoupled, + }; + }; + const sampleElementViewportCoupling = ( + element, widthExtent, heightExtent, viewportWidth, viewportHeight, + ) => { + const style = window.getComputedStyle(element); + const width = sampleAxisViewportCoupling( + widthExtent, style.width, style.minWidth, viewportWidth, + elementLayoutSamples.width.get(element), + ); + const height = sampleAxisViewportCoupling( + heightExtent, style.height, style.minHeight, viewportHeight, + elementLayoutSamples.height.get(element), + ); + elementLayoutSamples.width.set(element, width); + elementLayoutSamples.height.set(element, height); + return { + width: width.coupled, + height: height.coupled, + widthBaseline: width.baseline, + heightBaseline: height.baseline, + }; + }; + const sampleDocumentViewportCoupling = ( + previousLayoutSample, widthExtent, heightExtent, style, viewportWidth, viewportHeight, + ) => { + const width = sampleAxisViewportCoupling( + widthExtent, style?.width, style?.minWidth, viewportWidth, + previousLayoutSample.width, + ); + const height = sampleAxisViewportCoupling( + heightExtent, style?.height, style?.minHeight, viewportHeight, + previousLayoutSample.height, + ); + previousLayoutSample.width = width; + previousLayoutSample.height = height; + return { + width: width.coupled, + height: height.coupled, + widthBaseline: width.baseline, + heightBaseline: height.baseline, + }; + }; + const measureContentBounds = (body, viewportWidth, viewportHeight) => { + if (!body) return { width: 0, height: 0, truncated: false }; + const walker = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT); + let width = 0; + let height = 0; + let widthBaseline = 0; + let heightBaseline = 0; + let visited = 0; + let element = walker.nextNode(); + while (element && visited < maxContentProbes) { + visited += 1; + const rect = element.getBoundingClientRect(); + if (rect.width > 0 || rect.height > 0) { + const widthExtent = rect.right + window.scrollX; + const heightExtent = rect.bottom + window.scrollY; + const coupling = sampleElementViewportCoupling( + element, widthExtent, heightExtent, viewportWidth, viewportHeight, + ); + if (!coupling.width) width = Math.max(width, widthExtent); + if (!coupling.height) height = Math.max(height, heightExtent); + widthBaseline = Math.max(widthBaseline, coupling.widthBaseline || 0); + heightBaseline = Math.max(heightBaseline, coupling.heightBaseline || 0); + } + element = walker.nextNode(); + } + return { + width: Math.ceil(Math.max(0, width)), + height: Math.ceil(Math.max(0, height)), + widthBaseline: Math.ceil(Math.max(0, widthBaseline)), + heightBaseline: Math.ceil(Math.max(0, heightBaseline)), + truncated: element !== null, + }; + }; + const resolveIntrinsicExtent = (bodyExtent, bodyCoupled, contentExtent, truncated) => ( + truncated || !bodyCoupled + ? Math.max(bodyExtent, contentExtent) + : contentExtent + ); + const resolveContentExtent = ( + state, viewportExtent, scrollExtent, intrinsicExtent, probedExtent, truncated, + coupledBaseline, + ) => { + const viewportChanged = state.viewport !== undefined + && Math.abs(viewportExtent - state.viewport) >= 1; + const hostAppliedPrevious = state.resolved !== undefined + && Math.abs(viewportExtent - state.resolved) <= 1; + const nativeReset = viewportChanged && !hostAppliedPrevious; + if (nativeReset) { + state.responsive = false; + state.responsiveFloor = undefined; + state.responsiveCandidateCount = 0; + state.responsiveCandidateFloor = undefined; + state.responsiveCandidateDirection = 0; + resetLayoutCouplingSamples(state.axis); + } + const viewportDelta = state.viewport === undefined ? 0 : viewportExtent - state.viewport; + const scrollDelta = state.scroll === undefined ? 0 : scrollExtent - state.scroll; + const responsiveScroll = !nativeReset + && hostAppliedPrevious + && Math.abs(viewportDelta) >= 1 + && Math.abs(scrollDelta) >= 1 + && viewportDelta * scrollDelta > 0; + const candidateContradicted = !state.responsive + && (state.responsiveCandidateCount || 0) > 0 + && ( + (viewportChanged && hostAppliedPrevious && !responsiveScroll) + || ( + Math.abs(scrollDelta) >= 1 + && (state.responsiveCandidateDirection || 0) * scrollDelta < 0 + ) + ); + if (responsiveScroll && !state.responsive) { + const candidateFloor = truncated + ? Math.max(coupledBaseline || 0, state.resolved || 1) + : coupledBaseline || state.resolved; + if (truncated) { + state.responsive = true; + state.responsiveFloor = candidateFloor; + state.responsiveCandidateCount = 0; + state.responsiveCandidateFloor = undefined; + state.responsiveCandidateDirection = 0; + } else { + if ((state.responsiveCandidateCount || 0) === 0) { + state.responsiveCandidateDirection = Math.sign(viewportDelta); + } + state.responsiveCandidateCount = (state.responsiveCandidateCount || 0) + 1; + state.responsiveCandidateFloor = state.responsiveCandidateFloor || candidateFloor; + if (state.responsiveCandidateCount >= 2) { + state.responsive = true; + state.responsiveFloor = state.responsiveCandidateFloor; + state.responsiveCandidateCount = 0; + state.responsiveCandidateFloor = undefined; + state.responsiveCandidateDirection = 0; + } + } + } else if (candidateContradicted) { + state.responsiveCandidateCount = 0; + state.responsiveCandidateFloor = undefined; + state.responsiveCandidateDirection = 0; + } + const resolved = state.responsive + ? Math.max(state.responsiveFloor || 1, truncated ? probedExtent : intrinsicExtent) + : (scrollExtent > viewportExtent ? scrollExtent : intrinsicExtent); + if (state.responsive && truncated) { + state.responsiveFloor = Math.max(state.responsiveFloor || 1, resolved); + } + state.viewport = viewportExtent; + state.scroll = scrollExtent; + state.resolved = resolved; + return resolved; + }; + const publish = () => { + frame = 0; + if (!ready || window.parent === window) return; + const root = document.documentElement; + const body = document.body; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const rootRect = root?.getBoundingClientRect(); + const rootStyle = root ? window.getComputedStyle(root) : null; + const bodyRect = body?.getBoundingClientRect(); + const bodyStyle = body ? window.getComputedStyle(body) : null; + const horizontalMargin = (Number.parseFloat(bodyStyle?.marginLeft || '0') || 0) + + (Number.parseFloat(bodyStyle?.marginRight || '0') || 0); + const verticalMargin = (Number.parseFloat(bodyStyle?.marginTop || '0') || 0) + + (Number.parseFloat(bodyStyle?.marginBottom || '0') || 0); + const intrinsicBodyWidth = Math.ceil(Math.max(body?.offsetWidth || 0, bodyRect?.width || 0) + horizontalMargin); + const intrinsicBodyHeight = Math.ceil(Math.max(body?.offsetHeight || 0, bodyRect?.height || 0) + verticalMargin); + const intrinsicRootWidth = Math.ceil(Math.max( + root?.offsetWidth || 0, rootRect?.width || 0, (rootRect?.right || 0) + window.scrollX, + )); + const intrinsicRootHeight = Math.ceil(Math.max( + root?.offsetHeight || 0, rootRect?.height || 0, (rootRect?.bottom || 0) + window.scrollY, + )); + const rootWidthMirrorsBody = rootAxisMirrorsBody( + 'width', root, body, intrinsicRootWidth, intrinsicBodyWidth, + ); + const rootHeightMirrorsBody = rootAxisMirrorsBody( + 'height', root, body, intrinsicRootHeight, intrinsicBodyHeight, + ); + const rootCoupling = sampleDocumentViewportCoupling( + previousRootLayoutSample, + intrinsicRootWidth, intrinsicRootHeight, rootStyle, viewportWidth, viewportHeight, + ); + const bodyCoupling = sampleDocumentViewportCoupling( + previousBodyLayoutSample, + intrinsicBodyWidth, intrinsicBodyHeight, bodyStyle, viewportWidth, viewportHeight, + ); + const contentBounds = measureContentBounds(body, viewportWidth, viewportHeight); + const coupledWidthBaseline = Math.max( + rootWidthMirrorsBody ? 0 : rootCoupling.widthBaseline || 0, + bodyCoupling.widthBaseline || 0, + contentBounds.widthBaseline || 0, + ); + const coupledHeightBaseline = Math.max( + rootHeightMirrorsBody ? 0 : rootCoupling.heightBaseline || 0, + bodyCoupling.heightBaseline || 0, + contentBounds.heightBaseline || 0, + ); + const intrinsicBodyContentWidth = resolveIntrinsicExtent( + intrinsicBodyWidth, bodyCoupling.width, + contentBounds.width, contentBounds.truncated, + ); + const intrinsicBodyContentHeight = resolveIntrinsicExtent( + intrinsicBodyHeight, bodyCoupling.height, + contentBounds.height, contentBounds.truncated, + ); + const intrinsicContentWidth = Math.max( + coupledWidthBaseline, + resolveIntrinsicExtent( + intrinsicRootWidth, rootCoupling.width || rootWidthMirrorsBody, + intrinsicBodyContentWidth, contentBounds.truncated, + ), + ); + const intrinsicContentHeight = Math.max( + coupledHeightBaseline, + resolveIntrinsicExtent( + intrinsicRootHeight, rootCoupling.height || rootHeightMirrorsBody, + intrinsicBodyContentHeight, contentBounds.truncated, + ), + ); + const scrollWidth = Math.ceil(Math.max(root?.scrollWidth || 0, body?.scrollWidth || 0)); + const scrollHeight = Math.ceil(Math.max(root?.scrollHeight || 0, body?.scrollHeight || 0)); + const size = { + contentWidth: Math.max( + 1, resolveContentExtent( + contentResolutionState.width, viewportWidth, scrollWidth, intrinsicContentWidth, + contentBounds.width, contentBounds.truncated, coupledWidthBaseline, + ), + ), + contentHeight: Math.max( + 1, resolveContentExtent( + contentResolutionState.height, viewportHeight, scrollHeight, intrinsicContentHeight, + contentBounds.height, contentBounds.truncated, coupledHeightBaseline, + ), + ), + viewportWidth, + viewportHeight, + }; + const sizeKey = `${size.contentWidth}:${size.contentHeight}:${size.viewportWidth}:${size.viewportHeight}`; + if (sizeKey === lastPublishedSize) return; + lastPublishedSize = sizeKey; + window.parent.postMessage({ type: messageType, ...size }, '*'); + }; + const schedule = () => { + if (frame === 0) frame = window.requestAnimationFrame(publish); + }; + const activate = () => { + ready = true; + const style = document.createElement('style'); + style.dataset.genarrativePreviewFit = 'true'; + style.textContent = 'html,body{scrollbar-width:none}html::-webkit-scrollbar,body::-webkit-scrollbar{display:none}'; + (document.head || document.documentElement).appendChild(style); + schedule(); + if (typeof ResizeObserver === 'function') { + const resizeObserver = new ResizeObserver(schedule); + resizeObserver.observe(document.documentElement); + if (document.body) resizeObserver.observe(document.body); + } + document.addEventListener('load', schedule, true); + document.fonts?.ready.then(schedule); + window.addEventListener('resize', schedule, { passive: true }); + window.setInterval(() => { + if (!document.hidden) schedule(); + }, 500); + }; + const inspectMessageType = 'genarrative.local-preview-inspect.v1'; + const inspectState = { + enabled: false, + overlay: null, + current: null, + moveKey: null, + moveResolved: null, + }; + const inspectOverlay = () => { + if (inspectState.overlay) return inspectState.overlay; + const overlay = document.createElement('div'); + overlay.dataset.genarrativePreviewInspect = 'true'; + overlay.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #f97316;background:rgba(249,115,22,0.14);box-shadow:inset 0 0 0 1px rgba(255,255,255,0.85);'; + (document.body || document.documentElement).appendChild(overlay); + inspectState.overlay = overlay; + return overlay; + }; + const clearInspectOverlay = () => { + inspectState.overlay?.remove(); + inspectState.overlay = null; + inspectState.current = null; + }; + const inspectElementSummary = (element) => { + const rect = element.getBoundingClientRect(); + const tag = element.tagName ? element.tagName.toLowerCase() : 'div'; + const role = (element.getAttribute && element.getAttribute('role')) || ''; + const text = (element.innerText || element.textContent || '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120); + const resourceIds = []; + if (element.getAttribute) { + for (const name of ['data-resource-id', 'data-asset-id', 'data-genarrative-resource-id']) { + const value = element.getAttribute(name); + if (value && !resourceIds.includes(value)) resourceIds.push(value); + } + } + const media = element.querySelector ? element.querySelector('img,video,audio') : null; + const rawSource = + (element.getAttribute && element.getAttribute('src')) || + (media && media.getAttribute && media.getAttribute('src')) || + ''; + let sourcePath = ''; + if (rawSource) { + try { + sourcePath = new URL(rawSource, window.location.href).pathname; + } catch { + sourcePath = ''; + } + } + return { + label: text || tag, + elementTag: tag, + elementRole: role.slice(0, 80), + text, + width: Math.max(1, Math.round(rect.width)), + height: Math.max(1, Math.round(rect.height)), + resourceIds, + sourcePath, + }; + }; + // 引擎档只认玩法显式发布的句柄;句柄缺失或 engine 未知一律退化。 + const previewEngineHandle = () => { + try { + const handle = window.__GENARRATIVE_PREVIEW_GAME__; + return handle && typeof handle === 'object' ? handle : null; + } catch { + return null; + } + }; + const inspectTextValue = (value) => (typeof value === 'string' ? value.trim() : ''); + // 只接受层级化的同源路径/URL:blob:、data: 之类没有可用的 pathname,一律丢弃。 + const inspectUrlPathname = (value) => { + const trimmed = inspectTextValue(value); + if (!trimmed) return ''; + try { + const url = new URL(trimmed, window.location.href); + return /^(https?|file):$/u.test(url.protocol) ? url.pathname : ''; + } catch { + return ''; + } + }; + // 标签链:显式路径文件名去扩展名 → 贴图 URL 同样派生 → Phaser texture.key → 'canvas'。 + const inspectLabelFromSource = (value) => { + const trimmed = inspectTextValue(value); + if (!trimmed) return ''; + const pathname = inspectUrlPathname(trimmed); + const segment = pathname.split(/[\\/]/).filter(Boolean).pop() || ''; + const dot = segment.lastIndexOf('.'); + const stem = dot > 0 ? segment.slice(0, dot) : (dot === 0 ? '' : segment); + return stem.trim(); + }; + const inspectPointerRect = (clientX, clientY) => ({ + left: Number.isFinite(clientX) ? clientX : 0, + top: Number.isFinite(clientY) ? clientY : 0, + width: 1, + height: 1, + }); + const engineInspectSelection = (identity, rect) => ({ + label: identity.label || 'canvas', + elementTag: 'canvas', + elementRole: '', + text: '', + width: Math.max(1, Math.round(rect.width)), + height: Math.max(1, Math.round(rect.height)), + resourceIds: identity.resourceIds, + sourcePath: identity.sourcePath, + }); + const postInspect = (action, selection) => { + window.parent.postMessage({ type: inspectMessageType, action, selection }, '*'); + }; + const stopInspect = (cancelled) => { + if (!inspectState.enabled) return; + inspectState.enabled = false; + inspectState.moveKey = null; + inspectState.moveResolved = null; + document.removeEventListener('mousemove', handleInspectMove, true); + document.removeEventListener('click', handleInspectClick, true); + document.removeEventListener('keydown', handleInspectKeyDown, true); + clearInspectOverlay(); + postInspect(cancelled ? 'cancelled' : 'disabled'); + }; + // Phaser 4:只取当前激活场景;hitTestPointer 只覆盖 setInteractive() 对象,必须有几何兜底。 + const phaserActiveScenes = (game) => { + const scenes = []; + try { + const all = game && game.scene && game.scene.scenes; + if (!Array.isArray(all)) return scenes; + for (const scene of all) { + try { + if (scene && scene.sys && typeof scene.sys.isActive === 'function' && scene.sys.isActive()) { + scenes.push(scene); + } + } catch { + // 单个场景判断失败不影响其它场景。 + } + } + } catch { + // 拿不到场景列表时按未命中处理。 + } + return scenes; + }; + const phaserActivePointer = (game) => { + try { + const pointer = game && game.input && game.input.activePointer; + return pointer && typeof pointer === 'object' ? pointer : null; + } catch { + return null; + } + }; + // 本次点击坐标为准:用 Phaser 自己的换算(ScaleManager.transformX/Y,InputManager.transformPointer + // 用的就是这一对)把 DOM 坐标换成游戏坐标,不依赖最近一次 mousemove 留下的 activePointer.x/y。 + const phaserGamePoint = (game, canvasElement, clientX, clientY) => { + try { + const pageX = clientX + (Number(window.scrollX) || 0); + const pageY = clientY + (Number(window.scrollY) || 0); + const scale = game && game.scale; + if (scale && typeof scale.transformX === 'function' && typeof scale.transformY === 'function') { + const x = Number(scale.transformX(pageX)); + const y = Number(scale.transformY(pageY)); + if (Number.isFinite(x) && Number.isFinite(y)) return { x, y }; + } + const rect = canvasElement.getBoundingClientRect(); + const gameSize = scale && scale.gameSize; + const gameWidth = Number(gameSize && gameSize.width) + || Number(canvasElement.width) || Number(rect.width); + const gameHeight = Number(gameSize && gameSize.height) + || Number(canvasElement.height) || Number(rect.height); + if (!(rect.width > 0) || !(rect.height > 0) || !(gameWidth > 0) || !(gameHeight > 0)) return null; + const x = (pageX - (rect.left + (Number(window.scrollX) || 0))) * (gameWidth / rect.width); + const y = (pageY - (rect.top + (Number(window.scrollY) || 0))) * (gameHeight / rect.height); + return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null; + } catch { + return null; + } + }; + const phaserWorldPoint = (scene, gamePoint) => { + try { + const camera = scene && scene.cameras && scene.cameras.main; + if (camera && typeof camera.getWorldPoint === 'function') { + const point = camera.getWorldPoint(gamePoint.x, gamePoint.y); + if (point && Number.isFinite(point.x) && Number.isFinite(point.y)) { + return { x: point.x, y: point.y }; + } + } + } catch { + // 世界坐标换算失败按未命中处理。 + } + return null; + }; + /** + * `hitTestPointer` 返回的是 `scene.input._list` 顺序(= `setInteractive()` 注册顺序,**不是**叠放顺序: + * 真实 Phaser 4.2.1 实测里先注册上层时返回 `[top, bottom]`,断言见 `tests/runtimeInspectEngines.test.ts`)。 + * Phaser 自己在 `InputPlugin.update` / `updatePoll` 里紧接着调用 `sortGameObjects`(按 + * `pointer.camera.renderList` 索引降序)再用 `topOnly` 语义取 `[0]` 派发事件,这里跟它同一口径取最上层。 + */ + const phaserTopMostHit = (hits, pointer) => { + const renderList = pointer.camera && pointer.camera.renderList; + if (hits.length < 2 || !Array.isArray(renderList)) return hits[hits.length - 1]; + let top = hits[0]; + let topIndex = renderList.indexOf(top); + for (let index = 1; index < hits.length; index += 1) { + const candidateIndex = renderList.indexOf(hits[index]); + if (candidateIndex > topIndex) { + top = hits[index]; + topIndex = candidateIndex; + } + } + return top; + }; + // hitTest 期间把 activePointer 临时挪到本次点击坐标(Phaser 自己也这么更新 pointer),结束即还原全部字段。 + const phaserHitTestObject = (scene, pointer, gamePoint) => { + try { + const input = scene && scene.input; + if (!pointer || !input || typeof input.hitTestPointer !== 'function') return null; + const restore = { + x: pointer.x, + y: pointer.y, + worldX: pointer.worldX, + worldY: pointer.worldY, + camera: pointer.camera, + }; + let top = null; + try { + pointer.x = gamePoint.x; + pointer.y = gamePoint.y; + const hits = input.hitTestPointer(pointer); + if (Array.isArray(hits) && hits.length > 0) top = phaserTopMostHit(hits, pointer); + } finally { + pointer.x = restore.x; + pointer.y = restore.y; + pointer.worldX = restore.worldX; + pointer.worldY = restore.worldY; + pointer.camera = restore.camera; + } + return top && typeof top === 'object' ? top : null; + } catch { + return null; + } + }; + const phaserBoundsHitObject = (scene, gamePoint) => { + try { + const world = phaserWorldPoint(scene, gamePoint); + if (!world) return null; + const children = scene && scene.children && scene.children.list; + if (!Array.isArray(children)) return null; + const ordered = []; + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + if (!child || child.visible === false) continue; + const depth = Number(child.depth); + ordered.push({ child, depth: Number.isFinite(depth) ? depth : 0, index }); + } + ordered.sort((left, right) => ( + left.depth === right.depth ? left.index - right.index : left.depth - right.depth + )); + for (let index = ordered.length - 1; index >= 0; index -= 1) { + const child = ordered[index].child; + if (typeof child.getBounds !== 'function') continue; + let bounds = null; + try { + bounds = child.getBounds(); + } catch { + bounds = null; + } + if (!bounds) continue; + const x = Number(bounds.x); + const y = Number(bounds.y); + const width = Number(bounds.width); + const height = Number(bounds.height); + if (![x, y, width, height].every(Number.isFinite) || width < 0 || height < 0) continue; + if (world.x >= x && world.x <= x + width && world.y >= y && world.y <= y + height) { + return child; + } + } + } catch { + // 几何兜底失败按未命中处理。 + } + return null; + }; + const phaserTextureUrl = (gameObject) => { + try { + const texture = gameObject && gameObject.texture; + if (!texture) return ''; + const sourceImage = typeof texture.getSourceImage === 'function' ? texture.getSourceImage() : null; + if (sourceImage && (sourceImage.currentSrc || sourceImage.src)) { + return sourceImage.currentSrc || sourceImage.src; + } + const sources = Array.isArray(texture.source) ? texture.source : []; + for (const entry of sources) { + const image = entry && entry.image; + if (image && (image.currentSrc || image.src)) return image.currentSrc || image.src; + } + } catch { + // 贴图来源读取失败按无来源处理。 + } + return ''; + }; + const phaserResourceIdentity = (gameObject) => { + const identity = { resourceIds: [], sourcePath: '', label: '' }; + const readData = (key) => { + try { + return typeof gameObject.getData === 'function' ? gameObject.getData(key) : undefined; + } catch { + return undefined; + } + }; + const explicitId = inspectTextValue(readData('genarrativeResourceId')); + if (explicitId) identity.resourceIds.push(explicitId); + const explicitPath = inspectTextValue(readData('genarrativeResourcePath')); + if (explicitPath) { + identity.sourcePath = inspectUrlPathname(explicitPath); + identity.label = inspectLabelFromSource(explicitPath); + } + if (!identity.sourcePath) { + const textureUrl = phaserTextureUrl(gameObject); + if (textureUrl) { + identity.sourcePath = inspectUrlPathname(textureUrl); + identity.label = inspectLabelFromSource(textureUrl); + } + } + if (!identity.label) { + identity.label = inspectTextValue(gameObject && gameObject.texture && gameObject.texture.key); + } + return identity; + }; + // Phaser 4 的权威换算是 `camera.matrixCombined`(matrix × matrixExternal,Camera.preRender 组装), + // `camera.getWorldPoint` 求的正是它的逆;注意 v4 的 `camera.matrix` 只含 scroll/rotation/zoom, + // 不含相机 x/y,不能拿来做屏幕换算。 + const phaserWorldToGamePoint = (camera, worldX, worldY) => { + if (!camera) return null; + const combined = camera.matrixCombined; + if (combined && typeof combined.transformPoint === 'function') { + try { + const point = combined.transformPoint(worldX, worldY, { x: 0, y: 0 }); + if (point && Number.isFinite(point.x) && Number.isFinite(point.y)) { + return { x: point.x, y: point.y }; + } + } catch { + // 矩阵换算失败时退回下面的字段换算。 + } + } + const scrollX = Number(camera.scrollX); + const scrollY = Number(camera.scrollY); + const rotation = Number(camera.rotation); + if (!Number.isFinite(scrollX) || !Number.isFinite(scrollY)) return null; + if (Number.isFinite(rotation) && rotation !== 0) return null; + const zoom = Number(camera.zoom); + const cameraX = Number(camera.x); + const cameraY = Number(camera.y); + const width = Number(camera.width); + const height = Number(camera.height); + if (![zoom, cameraX, cameraY, width, height].every(Number.isFinite) || zoom === 0) return null; + const originX = Number.isFinite(Number(camera.originX)) ? Number(camera.originX) : 0.5; + const originY = Number.isFinite(Number(camera.originY)) ? Number(camera.originY) : 0.5; + const offsetX = width * originX; + const offsetY = height * originY; + const x = cameraX + offsetX + zoom * (worldX - scrollX - offsetX); + const y = cameraY + offsetY + zoom * (worldY - scrollY - offsetY); + if (!Number.isFinite(x) || !Number.isFinite(y)) return null; + return { x, y }; + }; + const phaserGameToClientRect = (game, canvasElement, camera, bounds) => { + try { + const canvasRect = canvasElement.getBoundingClientRect(); + const gameSize = game && game.scale && game.scale.gameSize; + const gameWidth = Number(gameSize && gameSize.width) + || Number(canvasElement.width) || Number(canvasRect.width); + const gameHeight = Number(gameSize && gameSize.height) + || Number(canvasElement.height) || Number(canvasRect.height); + if (!(gameWidth > 0) || !(gameHeight > 0)) return null; + if (!(canvasRect.width > 0) || !(canvasRect.height > 0)) return null; + const scaleX = canvasRect.width / gameWidth; + const scaleY = canvasRect.height / gameHeight; + const corners = [ + [bounds.left, bounds.top], + [bounds.right, bounds.top], + [bounds.right, bounds.bottom], + [bounds.left, bounds.bottom], + ]; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const [worldX, worldY] of corners) { + const point = phaserWorldToGamePoint(camera, worldX, worldY); + if (!point) return null; + minX = Math.min(minX, point.x); + minY = Math.min(minY, point.y); + maxX = Math.max(maxX, point.x); + maxY = Math.max(maxY, point.y); + } + const left = canvasRect.left + minX * scaleX; + const top = canvasRect.top + minY * scaleY; + const width = (maxX - minX) * scaleX; + const height = (maxY - minY) * scaleY; + if (![left, top, width, height].every(Number.isFinite)) return null; + if (!(width > 0) || !(height > 0)) return null; + return { left, top, width, height }; + } catch { + // 换算不稳时返回 null,由调用方退化成指针点矩形。 + return null; + } + }; + const phaserSelection = (scene, gameObject, game, canvasElement, clientX, clientY) => { + let boundsRect = null; + try { + if (typeof gameObject.getBounds === 'function') { + const bounds = gameObject.getBounds(); + if (bounds) { + const left = Number(bounds.x); + const top = Number(bounds.y); + const width = Number(bounds.width); + const height = Number(bounds.height); + if ([left, top, width, height].every(Number.isFinite)) { + const camera = scene && scene.cameras && scene.cameras.main; + boundsRect = phaserGameToClientRect(game, canvasElement, camera, { + left, + top, + right: left + width, + bottom: top + height, + }); + } + } + } + } catch { + boundsRect = null; + } + const rect = boundsRect || inspectPointerRect(clientX, clientY); + return { selection: engineInspectSelection(phaserResourceIdentity(gameObject), rect), rect }; + }; + const phaserInspectTarget = (handle, canvasElement, clientX, clientY) => { + const game = handle.game; + if (!game) return null; + const gamePoint = phaserGamePoint(game, canvasElement, clientX, clientY); + if (!gamePoint) return null; + const pointer = phaserActivePointer(game); + const scenes = phaserActiveScenes(game); + for (let index = scenes.length - 1; index >= 0; index -= 1) { + const scene = scenes[index]; + const gameObject = phaserHitTestObject(scene, pointer, gamePoint) + || phaserBoundsHitObject(scene, gamePoint); + if (gameObject) { + return phaserSelection(scene, gameObject, game, canvasElement, clientX, clientY); + } + } + return null; + }; + const THREE_TEXTURE_SLOTS = [ + 'map', + 'emissiveMap', + 'normalMap', + 'roughnessMap', + 'metalnessMap', + 'alphaMap', + 'aoMap', + 'lightMap', + 'bumpMap', + 'displacementMap', + 'specularMap', + 'envMap', + 'clearcoatMap', + 'clearcoatNormalMap', + 'clearcoatRoughnessMap', + 'iridescenceMap', + 'sheenColorMap', + 'sheenRoughnessMap', + 'transmissionMap', + 'thicknessMap', + 'anisotropyMap', + ]; + const threeTextureUrl = (texture) => { + try { + const image = texture && texture.image; + if (image && (image.currentSrc || image.src)) return image.currentSrc || image.src; + const data = texture && texture.source && texture.source.data; + if (data && (data.currentSrc || data.src)) return data.currentSrc || data.src; + } catch { + // 贴图来源读取失败按无来源处理。 + } + return ''; + }; + const threeTextureUrlFromObject = (object) => { + const material = object && object.material; + const materials = Array.isArray(material) ? material : material ? [material] : []; + for (const entry of materials) { + if (!entry || typeof entry !== 'object') continue; + for (const slot of THREE_TEXTURE_SLOTS) { + let texture = null; + try { + texture = entry[slot]; + } catch { + texture = null; + } + if (!texture) continue; + const url = threeTextureUrl(texture); + if (url) return url; + } + } + return ''; + }; + const threeResourceIdentity = (object) => { + const identity = { resourceIds: [], sourcePath: '', label: '' }; + const userData = (object && object.userData) || {}; + const explicitId = inspectTextValue(userData.genarrativeResourceId); + if (explicitId) identity.resourceIds.push(explicitId); + const explicitPath = inspectTextValue(userData.genarrativeResourcePath); + if (explicitPath) { + identity.sourcePath = inspectUrlPathname(explicitPath); + identity.label = inspectLabelFromSource(explicitPath); + } + if (!identity.sourcePath) { + const textureUrl = threeTextureUrlFromObject(object); + if (textureUrl) { + identity.sourcePath = inspectUrlPathname(textureUrl); + identity.label = inspectLabelFromSource(textureUrl); + } + } + return identity; + }; + // 命中对象世界包围盒八角投影取 min/max;取不到时不阻塞,退化成指针点矩形。 + const threeBoxScreenRect = (three, object, camera, domRect) => { + try { + if (typeof three.Box3 !== 'function' || typeof three.Vector3 !== 'function') return null; + const box = new three.Box3(); + if (typeof box.setFromObject !== 'function') return null; + box.setFromObject(object); + const min = box.min; + const max = box.max; + if (!min || !max) return null; + const xs = [Number(min.x), Number(max.x)]; + const ys = [Number(min.y), Number(max.y)]; + const zs = [Number(min.z), Number(max.z)]; + if (![...xs, ...ys, ...zs].every(Number.isFinite)) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let projected = 0; + for (const x of xs) { + for (const y of ys) { + for (const z of zs) { + const vector = new three.Vector3(x, y, z); + if (typeof vector.project !== 'function') return null; + vector.project(camera); + if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y)) continue; + const screenX = Number(domRect.left) + ((vector.x + 1) / 2) * Number(domRect.width); + const screenY = Number(domRect.top) + ((1 - vector.y) / 2) * Number(domRect.height); + if (!Number.isFinite(screenX) || !Number.isFinite(screenY)) continue; + minX = Math.min(minX, screenX); + minY = Math.min(minY, screenY); + maxX = Math.max(maxX, screenX); + maxY = Math.max(maxY, screenY); + projected += 1; + } + } + } + if (projected === 0) return null; + const width = maxX - minX; + const height = maxY - minY; + if (!(width > 0) || !(height > 0)) return null; + return { left: minX, top: minY, width, height }; + } catch { + return null; + } + }; + const threeInspectTarget = (handle, clientX, clientY) => { + const three = handle.THREE; + const scene = handle.scene; + const camera = handle.camera; + const renderer = handle.renderer; + const domElement = renderer && renderer.domElement; + if (!three || !scene || !camera || !domElement) return null; + if (typeof domElement.getBoundingClientRect !== 'function') return null; + if (typeof three.Vector2 !== 'function' || typeof three.Raycaster !== 'function') return null; + const domRect = domElement.getBoundingClientRect(); + const width = Number(domRect && domRect.width); + const height = Number(domRect && domRect.height); + if (!(width > 0) || !(height > 0)) return null; + const ndc = new three.Vector2( + ((clientX - Number(domRect.left)) / width) * 2 - 1, + -((clientY - Number(domRect.top)) / height) * 2 + 1, + ); + const raycaster = new three.Raycaster(); + if (typeof raycaster.setFromCamera !== 'function') return null; + raycaster.setFromCamera(ndc, camera); + if (typeof raycaster.intersectObjects !== 'function') return null; + const hits = raycaster.intersectObjects(scene.children, true); + const hit = Array.isArray(hits) && hits.length > 0 ? hits[0] : null; + const object = hit && hit.object; + if (!object) return null; + const rect = threeBoxScreenRect(three, object, camera, domRect) || inspectPointerRect(clientX, clientY); + return { selection: engineInspectSelection(threeResourceIdentity(object), rect), rect }; + }; + const resolveInspectEngineSelection = (canvasElement, clientX, clientY) => { + try { + const handle = previewEngineHandle(); + if (!handle) return null; + const engine = typeof handle.engine === 'string' ? handle.engine.trim().toLowerCase() : ''; + if (engine === 'phaser') return phaserInspectTarget(handle, canvasElement, clientX, clientY); + if (engine === 'three') return threeInspectTarget(handle, clientX, clientY); + } catch { + // 引擎档任何异常都按未命中处理,绝不抛给预览页。 + } + return null; + }; + const resolveInspectTarget = (target, clientX, clientY) => { + if (!(target instanceof Element)) return null; + if (typeof HTMLCanvasElement === 'function' && target instanceof HTMLCanvasElement) { + const engineTarget = resolveInspectEngineSelection(target, clientX, clientY); + if (engineTarget) return engineTarget; + } + const elementRect = target.getBoundingClientRect(); + return { + selection: inspectElementSummary(target), + rect: { + left: elementRect.left, + top: elementRect.top, + width: elementRect.width, + height: elementRect.height, + }, + }; + }; + const handleInspectMove = (event) => { + if (!inspectState.enabled) return; + const target = event.target; + if (!(target instanceof Element)) return; + inspectState.current = target; + // 同一坐标的重复 mousemove 不重复跑引擎 raycast(three.js 递归命中只在点选期间执行)。 + const moveKey = `${event.clientX}:${event.clientY}`; + if (inspectState.moveKey !== moveKey) { + inspectState.moveKey = moveKey; + inspectState.moveResolved = resolveInspectTarget(target, event.clientX, event.clientY); + } + const resolved = inspectState.moveResolved; + if (!resolved) return; + const rect = resolved.rect; + const overlay = inspectOverlay(); + overlay.style.left = `${rect.left}px`; + overlay.style.top = `${rect.top}px`; + overlay.style.width = `${rect.width}px`; + overlay.style.height = `${rect.height}px`; + }; + const handleInspectClick = (event) => { + if (!inspectState.enabled) return; + event.preventDefault(); + event.stopPropagation(); + const resolved = resolveInspectTarget(event.target, event.clientX, event.clientY); + if (resolved) postInspect('selected', resolved.selection); + stopInspect(false); + }; + const handleInspectKeyDown = (event) => { + if (!inspectState.enabled || event.key !== 'Escape') return; + event.preventDefault(); + stopInspect(true); + }; + const startInspect = () => { + if (inspectState.enabled) return; + inspectState.enabled = true; + inspectState.moveKey = null; + inspectState.moveResolved = null; + document.addEventListener('mousemove', handleInspectMove, true); + document.addEventListener('click', handleInspectClick, true); + document.addEventListener('keydown', handleInspectKeyDown, true); + postInspect('enabled'); + }; + window.addEventListener('message', (event) => { + if (event.source !== window.parent) return; + const data = event.data; + if (!data || data.type !== inspectMessageType) return; + if (data.action === 'enable') startInspect(); + else if (data.action === 'disable') stopInspect(false); + }); + if (ready) activate(); + else window.addEventListener('load', activate, { once: true }); +})(); \ No newline at end of file diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index 8ca838f6d..2f617ce1a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -108,514 +108,7 @@ const PREVIEW_REQUEST_MAX_HEADER_LINES: usize = 100; const PREVIEW_RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_millis(250); const PREVIEW_RESPONSE_DRAIN_MAX_BYTES: usize = 32 * 1024; const PREVIEW_FIT_BRIDGE_PATH: &str = "/__genarrative/local-preview-fit.js"; -const PREVIEW_FIT_BRIDGE_SCRIPT: &str = r#"(() => { - const messageType = 'genarrative.local-preview-size.v1'; - const maxContentProbes = 512; - let ready = document.readyState === 'complete'; - let frame = 0; - let lastPublishedSize = ''; - const elementLayoutSamples = { width: new WeakMap(), height: new WeakMap() }; - const previousRootLayoutSample = {}; - const previousBodyLayoutSample = {}; - const rootIndependentLayout = { width: false, height: false }; - const contentResolutionState = { width: { axis: 'width' }, height: { axis: 'height' } }; - const resetLayoutCouplingSamples = (axis) => { - elementLayoutSamples[axis] = new WeakMap(); - previousRootLayoutSample[axis] = undefined; - previousBodyLayoutSample[axis] = undefined; - }; - const resolveViewportCoupling = ( - extent, viewport, previousExtent, previousViewport, previousCoupled, - ) => { - if (extent === undefined || previousExtent === undefined || previousViewport === undefined) { - return false; - } - const viewportDelta = viewport - previousViewport; - if (Math.abs(viewportDelta) < 1) { - return Math.abs(extent - previousExtent) < 1 ? previousCoupled : false; - } - const extentDelta = extent - previousExtent; - return Math.abs(extentDelta) >= 1 && extentDelta * viewportDelta > 0; - }; - const previewStyleExtent = (value) => { - const extent = Number.parseFloat(value || ''); - return Number.isFinite(extent) ? extent : undefined; - }; - const previewTypedStyleValue = (element, property) => { - try { - return element?.computedStyleMap?.().get(property)?.toString().trim().toLowerCase(); - } catch { - return undefined; - } - }; - const previewTypedMinimumCreatesExtent = (value) => { - if (value === undefined || value === 'auto' || value === 'none') return false; - const extent = Number.parseFloat(value); - return Number.isFinite(extent) ? extent > 0 : true; - }; - const rootAxisMirrorsBody = (axis, root, body, rootExtent, bodyExtent) => { - if (!root || !body) return false; - const sameExtent = Math.abs(rootExtent - bodyExtent) <= 1; - const sizeProperty = axis === 'width' ? 'width' : 'height'; - const minimumProperty = axis === 'width' ? 'min-width' : 'min-height'; - const typedSize = previewTypedStyleValue(root, sizeProperty); - const typedMinimum = previewTypedStyleValue(root, minimumProperty); - if (typedSize !== undefined || typedMinimum !== undefined) { - rootIndependentLayout[axis] = (typedSize !== undefined && typedSize !== 'auto') - || previewTypedMinimumCreatesExtent(typedMinimum); - } else if (!sameExtent) { - rootIndependentLayout[axis] = true; - } - return sameExtent && !rootIndependentLayout[axis]; - }; - const previewStyleUsesViewport = (value) => ( - (value || '').includes('%') - || /\b(?:v[wh]|vmin|vmax|sv[wh]|lv[wh]|dv[wh]|vi|vb|svi|svb|lvi|lvb|dvi|dvb)\b/i.test(value || '') - ); - const previewViewportStyleBaseline = (value, viewport) => { - const normalized = (value || '').trim().toLowerCase(); - const amount = Number.parseFloat(normalized); - if (!Number.isFinite(amount) || normalized.includes('calc(')) return undefined; - if (normalized.endsWith('%')) return viewport * amount / 100; - if (/^-?\d*\.?\d+(?:d?v[wh]|s?v[wh]|l?v[wh])$/.test(normalized)) { - return viewport * amount / 100; - } - return undefined; - }; - const sampleAxisViewportCoupling = ( - edge, sizeStyle, minimumStyle, viewport, previous, - ) => { - const size = previewStyleExtent(sizeStyle); - const minimum = previewStyleExtent(minimumStyle); - const sizeUsesViewport = previewStyleUsesViewport(sizeStyle); - const minimumUsesViewport = previewStyleUsesViewport(minimumStyle); - const edgeCoupled = resolveViewportCoupling( - edge, viewport, previous?.edge, previous?.viewport, previous?.edgeCoupled || false, - ); - const sizeCoupled = sizeUsesViewport || resolveViewportCoupling( - size, viewport, previous?.size, previous?.viewport, - previous?.sizeUsesViewport && !sizeUsesViewport ? false : previous?.sizeCoupled || false, - ); - const minimumCoupled = minimumUsesViewport || resolveViewportCoupling( - minimum, viewport, previous?.minimum, previous?.viewport, - previous?.minimumUsesViewport && !minimumUsesViewport - ? false - : previous?.minimumCoupled || false, - ); - const edgeBaseline = edgeCoupled - ? previous?.edgeBaseline ?? previous?.baseline ?? previous?.edge ?? edge - : undefined; - const sizeBaseline = sizeCoupled - ? previous?.sizeBaseline - ?? previous?.baseline - ?? previous?.size - ?? previewViewportStyleBaseline(sizeStyle, viewport) - ?? size - : undefined; - const minimumBaseline = minimumCoupled - ? previous?.minimumBaseline - ?? previous?.baseline - ?? previous?.minimum - ?? previewViewportStyleBaseline(minimumStyle, viewport) - ?? minimum - : undefined; - const baseline = Math.max( - 0, edgeBaseline || 0, sizeBaseline || 0, minimumBaseline || 0, - ) || undefined; - return { - edge, - size, - minimum, - viewport, - edgeCoupled, - sizeCoupled, - minimumCoupled, - edgeBaseline, - sizeBaseline, - minimumBaseline, - sizeUsesViewport, - minimumUsesViewport, - baseline, - coupled: edgeCoupled || sizeCoupled || minimumCoupled, - }; - }; - const sampleElementViewportCoupling = ( - element, widthExtent, heightExtent, viewportWidth, viewportHeight, - ) => { - const style = window.getComputedStyle(element); - const width = sampleAxisViewportCoupling( - widthExtent, style.width, style.minWidth, viewportWidth, - elementLayoutSamples.width.get(element), - ); - const height = sampleAxisViewportCoupling( - heightExtent, style.height, style.minHeight, viewportHeight, - elementLayoutSamples.height.get(element), - ); - elementLayoutSamples.width.set(element, width); - elementLayoutSamples.height.set(element, height); - return { - width: width.coupled, - height: height.coupled, - widthBaseline: width.baseline, - heightBaseline: height.baseline, - }; - }; - const sampleDocumentViewportCoupling = ( - previousLayoutSample, widthExtent, heightExtent, style, viewportWidth, viewportHeight, - ) => { - const width = sampleAxisViewportCoupling( - widthExtent, style?.width, style?.minWidth, viewportWidth, - previousLayoutSample.width, - ); - const height = sampleAxisViewportCoupling( - heightExtent, style?.height, style?.minHeight, viewportHeight, - previousLayoutSample.height, - ); - previousLayoutSample.width = width; - previousLayoutSample.height = height; - return { - width: width.coupled, - height: height.coupled, - widthBaseline: width.baseline, - heightBaseline: height.baseline, - }; - }; - const measureContentBounds = (body, viewportWidth, viewportHeight) => { - if (!body) return { width: 0, height: 0, truncated: false }; - const walker = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT); - let width = 0; - let height = 0; - let widthBaseline = 0; - let heightBaseline = 0; - let visited = 0; - let element = walker.nextNode(); - while (element && visited < maxContentProbes) { - visited += 1; - const rect = element.getBoundingClientRect(); - if (rect.width > 0 || rect.height > 0) { - const widthExtent = rect.right + window.scrollX; - const heightExtent = rect.bottom + window.scrollY; - const coupling = sampleElementViewportCoupling( - element, widthExtent, heightExtent, viewportWidth, viewportHeight, - ); - if (!coupling.width) width = Math.max(width, widthExtent); - if (!coupling.height) height = Math.max(height, heightExtent); - widthBaseline = Math.max(widthBaseline, coupling.widthBaseline || 0); - heightBaseline = Math.max(heightBaseline, coupling.heightBaseline || 0); - } - element = walker.nextNode(); - } - return { - width: Math.ceil(Math.max(0, width)), - height: Math.ceil(Math.max(0, height)), - widthBaseline: Math.ceil(Math.max(0, widthBaseline)), - heightBaseline: Math.ceil(Math.max(0, heightBaseline)), - truncated: element !== null, - }; - }; - const resolveIntrinsicExtent = (bodyExtent, bodyCoupled, contentExtent, truncated) => ( - truncated || !bodyCoupled - ? Math.max(bodyExtent, contentExtent) - : contentExtent - ); - const resolveContentExtent = ( - state, viewportExtent, scrollExtent, intrinsicExtent, probedExtent, truncated, - coupledBaseline, - ) => { - const viewportChanged = state.viewport !== undefined - && Math.abs(viewportExtent - state.viewport) >= 1; - const hostAppliedPrevious = state.resolved !== undefined - && Math.abs(viewportExtent - state.resolved) <= 1; - const nativeReset = viewportChanged && !hostAppliedPrevious; - if (nativeReset) { - state.responsive = false; - state.responsiveFloor = undefined; - state.responsiveCandidateCount = 0; - state.responsiveCandidateFloor = undefined; - state.responsiveCandidateDirection = 0; - resetLayoutCouplingSamples(state.axis); - } - const viewportDelta = state.viewport === undefined ? 0 : viewportExtent - state.viewport; - const scrollDelta = state.scroll === undefined ? 0 : scrollExtent - state.scroll; - const responsiveScroll = !nativeReset - && hostAppliedPrevious - && Math.abs(viewportDelta) >= 1 - && Math.abs(scrollDelta) >= 1 - && viewportDelta * scrollDelta > 0; - const candidateContradicted = !state.responsive - && (state.responsiveCandidateCount || 0) > 0 - && ( - (viewportChanged && hostAppliedPrevious && !responsiveScroll) - || ( - Math.abs(scrollDelta) >= 1 - && (state.responsiveCandidateDirection || 0) * scrollDelta < 0 - ) - ); - if (responsiveScroll && !state.responsive) { - const candidateFloor = truncated - ? Math.max(coupledBaseline || 0, state.resolved || 1) - : coupledBaseline || state.resolved; - if (truncated) { - state.responsive = true; - state.responsiveFloor = candidateFloor; - state.responsiveCandidateCount = 0; - state.responsiveCandidateFloor = undefined; - state.responsiveCandidateDirection = 0; - } else { - if ((state.responsiveCandidateCount || 0) === 0) { - state.responsiveCandidateDirection = Math.sign(viewportDelta); - } - state.responsiveCandidateCount = (state.responsiveCandidateCount || 0) + 1; - state.responsiveCandidateFloor = state.responsiveCandidateFloor || candidateFloor; - if (state.responsiveCandidateCount >= 2) { - state.responsive = true; - state.responsiveFloor = state.responsiveCandidateFloor; - state.responsiveCandidateCount = 0; - state.responsiveCandidateFloor = undefined; - state.responsiveCandidateDirection = 0; - } - } - } else if (candidateContradicted) { - state.responsiveCandidateCount = 0; - state.responsiveCandidateFloor = undefined; - state.responsiveCandidateDirection = 0; - } - const resolved = state.responsive - ? Math.max(state.responsiveFloor || 1, truncated ? probedExtent : intrinsicExtent) - : (scrollExtent > viewportExtent ? scrollExtent : intrinsicExtent); - if (state.responsive && truncated) { - state.responsiveFloor = Math.max(state.responsiveFloor || 1, resolved); - } - state.viewport = viewportExtent; - state.scroll = scrollExtent; - state.resolved = resolved; - return resolved; - }; - const publish = () => { - frame = 0; - if (!ready || window.parent === window) return; - const root = document.documentElement; - const body = document.body; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const rootRect = root?.getBoundingClientRect(); - const rootStyle = root ? window.getComputedStyle(root) : null; - const bodyRect = body?.getBoundingClientRect(); - const bodyStyle = body ? window.getComputedStyle(body) : null; - const horizontalMargin = (Number.parseFloat(bodyStyle?.marginLeft || '0') || 0) - + (Number.parseFloat(bodyStyle?.marginRight || '0') || 0); - const verticalMargin = (Number.parseFloat(bodyStyle?.marginTop || '0') || 0) - + (Number.parseFloat(bodyStyle?.marginBottom || '0') || 0); - const intrinsicBodyWidth = Math.ceil(Math.max(body?.offsetWidth || 0, bodyRect?.width || 0) + horizontalMargin); - const intrinsicBodyHeight = Math.ceil(Math.max(body?.offsetHeight || 0, bodyRect?.height || 0) + verticalMargin); - const intrinsicRootWidth = Math.ceil(Math.max( - root?.offsetWidth || 0, rootRect?.width || 0, (rootRect?.right || 0) + window.scrollX, - )); - const intrinsicRootHeight = Math.ceil(Math.max( - root?.offsetHeight || 0, rootRect?.height || 0, (rootRect?.bottom || 0) + window.scrollY, - )); - const rootWidthMirrorsBody = rootAxisMirrorsBody( - 'width', root, body, intrinsicRootWidth, intrinsicBodyWidth, - ); - const rootHeightMirrorsBody = rootAxisMirrorsBody( - 'height', root, body, intrinsicRootHeight, intrinsicBodyHeight, - ); - const rootCoupling = sampleDocumentViewportCoupling( - previousRootLayoutSample, - intrinsicRootWidth, intrinsicRootHeight, rootStyle, viewportWidth, viewportHeight, - ); - const bodyCoupling = sampleDocumentViewportCoupling( - previousBodyLayoutSample, - intrinsicBodyWidth, intrinsicBodyHeight, bodyStyle, viewportWidth, viewportHeight, - ); - const contentBounds = measureContentBounds(body, viewportWidth, viewportHeight); - const coupledWidthBaseline = Math.max( - rootWidthMirrorsBody ? 0 : rootCoupling.widthBaseline || 0, - bodyCoupling.widthBaseline || 0, - contentBounds.widthBaseline || 0, - ); - const coupledHeightBaseline = Math.max( - rootHeightMirrorsBody ? 0 : rootCoupling.heightBaseline || 0, - bodyCoupling.heightBaseline || 0, - contentBounds.heightBaseline || 0, - ); - const intrinsicBodyContentWidth = resolveIntrinsicExtent( - intrinsicBodyWidth, bodyCoupling.width, - contentBounds.width, contentBounds.truncated, - ); - const intrinsicBodyContentHeight = resolveIntrinsicExtent( - intrinsicBodyHeight, bodyCoupling.height, - contentBounds.height, contentBounds.truncated, - ); - const intrinsicContentWidth = Math.max( - coupledWidthBaseline, - resolveIntrinsicExtent( - intrinsicRootWidth, rootCoupling.width || rootWidthMirrorsBody, - intrinsicBodyContentWidth, contentBounds.truncated, - ), - ); - const intrinsicContentHeight = Math.max( - coupledHeightBaseline, - resolveIntrinsicExtent( - intrinsicRootHeight, rootCoupling.height || rootHeightMirrorsBody, - intrinsicBodyContentHeight, contentBounds.truncated, - ), - ); - const scrollWidth = Math.ceil(Math.max(root?.scrollWidth || 0, body?.scrollWidth || 0)); - const scrollHeight = Math.ceil(Math.max(root?.scrollHeight || 0, body?.scrollHeight || 0)); - const size = { - contentWidth: Math.max( - 1, resolveContentExtent( - contentResolutionState.width, viewportWidth, scrollWidth, intrinsicContentWidth, - contentBounds.width, contentBounds.truncated, coupledWidthBaseline, - ), - ), - contentHeight: Math.max( - 1, resolveContentExtent( - contentResolutionState.height, viewportHeight, scrollHeight, intrinsicContentHeight, - contentBounds.height, contentBounds.truncated, coupledHeightBaseline, - ), - ), - viewportWidth, - viewportHeight, - }; - const sizeKey = `${size.contentWidth}:${size.contentHeight}:${size.viewportWidth}:${size.viewportHeight}`; - if (sizeKey === lastPublishedSize) return; - lastPublishedSize = sizeKey; - window.parent.postMessage({ type: messageType, ...size }, '*'); - }; - const schedule = () => { - if (frame === 0) frame = window.requestAnimationFrame(publish); - }; - const activate = () => { - ready = true; - const style = document.createElement('style'); - style.dataset.genarrativePreviewFit = 'true'; - style.textContent = 'html,body{scrollbar-width:none}html::-webkit-scrollbar,body::-webkit-scrollbar{display:none}'; - (document.head || document.documentElement).appendChild(style); - schedule(); - if (typeof ResizeObserver === 'function') { - const resizeObserver = new ResizeObserver(schedule); - resizeObserver.observe(document.documentElement); - if (document.body) resizeObserver.observe(document.body); - } - document.addEventListener('load', schedule, true); - document.fonts?.ready.then(schedule); - window.addEventListener('resize', schedule, { passive: true }); - window.setInterval(() => { - if (!document.hidden) schedule(); - }, 500); - }; - const inspectMessageType = 'genarrative.local-preview-inspect.v1'; - const inspectState = { enabled: false, overlay: null, current: null }; - const inspectOverlay = () => { - if (inspectState.overlay) return inspectState.overlay; - const overlay = document.createElement('div'); - overlay.dataset.genarrativePreviewInspect = 'true'; - overlay.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #f97316;background:rgba(249,115,22,0.14);box-shadow:inset 0 0 0 1px rgba(255,255,255,0.85);'; - (document.body || document.documentElement).appendChild(overlay); - inspectState.overlay = overlay; - return overlay; - }; - const clearInspectOverlay = () => { - inspectState.overlay?.remove(); - inspectState.overlay = null; - inspectState.current = null; - }; - const inspectElementSummary = (element) => { - const rect = element.getBoundingClientRect(); - const tag = element.tagName ? element.tagName.toLowerCase() : 'div'; - const role = (element.getAttribute && element.getAttribute('role')) || ''; - const text = (element.innerText || element.textContent || '') - .replace(/\s+/g, ' ') - .trim() - .slice(0, 120); - const resourceIds = []; - if (element.getAttribute) { - for (const name of ['data-resource-id', 'data-asset-id', 'data-genarrative-resource-id']) { - const value = element.getAttribute(name); - if (value && !resourceIds.includes(value)) resourceIds.push(value); - } - } - const media = element.querySelector ? element.querySelector('img,video,audio') : null; - const rawSource = - (element.getAttribute && element.getAttribute('src')) || - (media && media.getAttribute && media.getAttribute('src')) || - ''; - let sourcePath = ''; - if (rawSource) { - try { - sourcePath = new URL(rawSource, window.location.href).pathname; - } catch { - sourcePath = ''; - } - } - return { - label: text || tag, - elementTag: tag, - elementRole: role.slice(0, 80), - text, - width: Math.max(1, Math.round(rect.width)), - height: Math.max(1, Math.round(rect.height)), - resourceIds, - sourcePath, - }; - }; - const postInspect = (action, selection) => { - window.parent.postMessage({ type: inspectMessageType, action, selection }, '*'); - }; - const stopInspect = (cancelled) => { - if (!inspectState.enabled) return; - inspectState.enabled = false; - document.removeEventListener('mousemove', handleInspectMove, true); - document.removeEventListener('click', handleInspectClick, true); - document.removeEventListener('keydown', handleInspectKeyDown, true); - clearInspectOverlay(); - postInspect(cancelled ? 'cancelled' : 'disabled'); - }; - const handleInspectMove = (event) => { - if (!inspectState.enabled) return; - const target = event.target; - if (!(target instanceof Element)) return; - inspectState.current = target; - const rect = target.getBoundingClientRect(); - const overlay = inspectOverlay(); - overlay.style.left = `${rect.left}px`; - overlay.style.top = `${rect.top}px`; - overlay.style.width = `${rect.width}px`; - overlay.style.height = `${rect.height}px`; - }; - const handleInspectClick = (event) => { - if (!inspectState.enabled) return; - event.preventDefault(); - event.stopPropagation(); - const target = event.target; - if (target instanceof Element) postInspect('selected', inspectElementSummary(target)); - stopInspect(false); - }; - const handleInspectKeyDown = (event) => { - if (!inspectState.enabled || event.key !== 'Escape') return; - event.preventDefault(); - stopInspect(true); - }; - const startInspect = () => { - if (inspectState.enabled) return; - inspectState.enabled = true; - document.addEventListener('mousemove', handleInspectMove, true); - document.addEventListener('click', handleInspectClick, true); - document.addEventListener('keydown', handleInspectKeyDown, true); - postInspect('enabled'); - }; - window.addEventListener('message', (event) => { - if (event.source !== window.parent) return; - const data = event.data; - if (!data || data.type !== inspectMessageType) return; - if (data.action === 'enable') startInspect(); - else if (data.action === 'disable') stopInspect(false); - }); - if (ready) activate(); - else window.addEventListener('load', activate, { once: true }); -})();"#; +const PREVIEW_FIT_BRIDGE_SCRIPT: &str = include_str!("../resources/preview/local-preview-fit.js"); const PREVIEW_FIT_BRIDGE_TAG: &str = r#""#; diff --git a/apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts b/apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts new file mode 100644 index 000000000..087be4370 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts @@ -0,0 +1,1277 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs'; + +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { parseLocalGamePreviewInspectMessage } from '../src/features/project-workspace/LocalGamePreviewFrame'; +import { repoPath } from './repoPath'; + +/** + * 运行画面点选桥(`src-tauri/resources/preview/local-preview-fit.js`)的引擎档用例。 + * + * 桥脚本随 Rust 源码 `include_str!` 打包,这里用 fs 读同一份文件、`new Function` 执行, + * 因此断言的就是随包进客户端的那段代码。jsdom 里 `window.parent` 就是自己,spy 它的 + * `postMessage` 即可拿到 `genarrative.local-preview-inspect.v1` 载荷。 + * + * 引擎句柄契约:`window.__GENARRATIVE_PREVIEW_GAME__`(Phaser `{ engine, game }` / + * three `{ engine, THREE, scene, camera, renderer }`),桥端惰性读取、只认显式句柄。 + */ +const BRIDGE_SOURCE = readFileSync( + repoPath( + 'apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js', + ), + 'utf8', +); +const INSPECT_MESSAGE = 'genarrative.local-preview-inspect.v1'; + +/** 本地预览桥自己的消息协议;测试按契约直接读,不做逐字段复检。 */ +type BridgePayload = { + type?: string; + action?: string; + selection?: Record; +}; + +/** + * 只给这个属性一个宽松视图:`window` 上同名属性的全局声明由脚手架 smoke 页面维护 + * (`src/smokePickingGame*.ts`,形态是真 Phaser / three 实例),这里放的是假引擎对象, + * 用 `unknown` 双断言避开那层声明,不依赖也不修改它。 + */ +type PreviewBridgeWindow = { __GENARRATIVE_PREVIEW_GAME__?: unknown }; + +type StubRect = { left: number; top: number; width: number; height: number }; + +type FakeBounds = { x: number; y: number; width: number; height: number }; + +type FakeSpriteTexture = { + key: string; + getSourceImage?: () => { currentSrc?: string; src?: string }; + source?: { image: { currentSrc?: string; src?: string } }[]; +}; + +type FakeSprite = { + visible: boolean; + depth: number; + texture: FakeSpriteTexture; + getBounds: () => FakeBounds; + getData: (key: string) => string | undefined; +}; + +type FakePointer = { + x: number; + y: number; + worldX?: number; + worldY?: number; + camera?: unknown; +}; + +type FakePhaserCamera = { + scrollX: number; + scrollY: number; + zoom: number; + x: number; + y: number; + width: number; + height: number; + originX: number; + originY: number; + rotation: number; + /** Phaser 4 的权威屏幕换算矩阵(`Camera.preRender` 组装,`getWorldPoint` 求它的逆)。 */ + matrixCombined?: { + transformPoint: ( + x: number, + y: number, + out: { x: number; y: number }, + ) => { x: number; y: number }; + }; + /** `sortGameObjects` 用的渲染顺序表:索引越大越靠上。 */ + renderList?: unknown[]; + getWorldPoint: (x: number, y: number) => { x: number; y: number }; +}; + +type FakePhaserScene = { + sys: { isActive: () => boolean }; + cameras: { main: FakePhaserCamera }; + input: { hitTestPointer: (pointer: FakePointer) => unknown[] }; + children: { list: unknown[] }; +}; + +const posted: BridgePayload[] = []; +const bridgeWindow = window as unknown as PreviewBridgeWindow; + +const spriteData = new WeakMap>(); + +/** 假网格的包围盒:桥只经 THREE.Box3.setFromObject 取,这里按对象登记。 */ +const meshBox = new WeakMap< + object, + { + min: { x: number; y: number; z: number }; + max: { x: number; y: number; z: number }; + } +>(); + +function createSprite(options: { + bounds: StubRect; + data?: Record; + textureKey?: string; + sourceImage?: { currentSrc?: string; src?: string }; + textureSource?: { currentSrc?: string; src?: string }; + depth?: number; + visible?: boolean; +}): FakeSprite { + const texture: FakeSpriteTexture = { key: options.textureKey ?? '' }; + const sourceImage = options.sourceImage; + if (sourceImage) texture.getSourceImage = () => sourceImage; + const textureSource = options.textureSource; + if (textureSource) texture.source = [{ image: textureSource }]; + const sprite: FakeSprite = { + visible: options.visible ?? true, + depth: options.depth ?? 0, + texture, + getBounds: () => ({ + x: options.bounds.left, + y: options.bounds.top, + width: options.bounds.width, + height: options.bounds.height, + }), + getData: (key: string) => spriteData.get(sprite)?.[key], + }; + spriteData.set(sprite, options.data ?? {}); + return sprite; +} + +function createPhaserCamera( + overrides: Partial = {}, +): FakePhaserCamera { + return { + scrollX: 0, + scrollY: 0, + zoom: 1, + x: 0, + y: 0, + width: 800, + height: 600, + originX: 0.5, + originY: 0.5, + rotation: 0, + getWorldPoint: (x: number, y: number) => ({ x, y }), + ...overrides, + }; +} + +function createPhaserScene(options: { + active: boolean; + camera?: FakePhaserCamera; + interactiveHits?: unknown[]; + children?: unknown[]; +}): FakePhaserScene { + const camera = options.camera ?? createPhaserCamera(); + return { + sys: { isActive: () => options.active }, + cameras: { main: camera }, + // 与 Phaser 一致:hitTestPointer 命中后会把 pointer.camera 设成参与命中的相机。 + input: { + hitTestPointer: (pointer: FakePointer) => { + const hits = options.interactiveHits ?? []; + if (hits.length > 0) pointer.camera = camera; + return hits; + }, + }, + children: { list: options.children ?? [] }, + }; +} + +function createPhaserGame(options: { + canvas: HTMLCanvasElement; + pointer?: FakePointer | null; + scenes: FakePhaserScene[]; +}) { + return { + canvas: options.canvas, + input: { activePointer: options.pointer ?? null }, + scene: { scenes: options.scenes }, + // 与 ScaleManager.transformX/Y 同式:canvasBounds 取 canvas 可见矩形,displayScale 按 1:1 + // (假 canvas 的 CSS 尺寸就是游戏尺寸)。 + scale: { + gameSize: { width: 800, height: 600 }, + transformX: (pageX: number) => + pageX - options.canvas.getBoundingClientRect().left, + transformY: (pageY: number) => + pageY - options.canvas.getBoundingClientRect().top, + }, + }; +} + +type FakeThreeHit = { object: unknown; distance: number }; + +type FakeVector3 = { + x: number; + y: number; + z: number; + project: (camera: unknown) => FakeVector3; +}; + +type FakeThreeNamespace = { + Vector2: new (x: number, y: number) => { x: number; y: number }; + Vector3: new (x: number, y: number, z: number) => FakeVector3; + Box3: new () => { + min: { x: number; y: number; z: number }; + max: { x: number; y: number; z: number }; + setFromObject: (object: unknown) => unknown; + }; + Raycaster: new () => { + setFromCamera: (ndc: { x: number; y: number }, camera: unknown) => void; + intersectObjects: (objects: unknown, recursive: boolean) => FakeThreeHit[]; + }; +}; + +const threeProbe: { + ndc?: { x: number; y: number }; + camera?: unknown; + objects?: unknown; + recursive?: boolean; +} = {}; + +/** 世界坐标 → NDC 的假投影(按 800x600 画面):断言的是桥给的归一化与取近优先。 */ +const projection = (x: number, y: number) => ({ + x: x / 400 - 1, + y: 1 - y / 300, +}); + +function createThreeNamespace(options: { + hits: FakeThreeHit[]; + /** 世界坐标 → NDC 的假投影:够用即可,断言的是桥给的归一化与取近优先。 */ + projection: (x: number, y: number, z: number) => { x: number; y: number }; +}): FakeThreeNamespace { + class Vector2 { + constructor( + public x: number, + public y: number, + ) {} + } + class Vector3 { + constructor( + public x: number, + public y: number, + public z: number, + ) {} + + project(): FakeVector3 { + const projected = options.projection(this.x, this.y, this.z); + this.x = projected.x; + this.y = projected.y; + return this; + } + } + class Box3 { + min = { x: 0, y: 0, z: 0 }; + max = { x: 0, y: 0, z: 0 }; + + setFromObject(object: unknown): Box3 { + if (object && typeof object === 'object') { + const box = meshBox.get(object); + if (box) { + this.min = { ...box.min }; + this.max = { ...box.max }; + } + } + return this; + } + } + class Raycaster { + setFromCamera(ndc: { x: number; y: number }, camera: unknown): void { + threeProbe.ndc = ndc; + threeProbe.camera = camera; + } + + intersectObjects(objects: unknown, recursive: boolean): FakeThreeHit[] { + threeProbe.objects = objects; + threeProbe.recursive = recursive; + return options.hits; + } + } + return { Vector2, Vector3, Box3, Raycaster }; +} + +function createThreeMesh(options: { + userData?: Record; + material?: unknown; + box?: StubRect; +}) { + const mesh = { + userData: options.userData ?? {}, + material: options.material ?? {}, + }; + if (options.box) { + meshBox.set(mesh, { + min: { x: options.box.left, y: options.box.top, z: 0 }, + max: { + x: options.box.left + options.box.width, + y: options.box.top + options.box.height, + z: 0, + }, + }); + } + return mesh; +} + +function stubRect(element: Element, rect: StubRect): void { + // `as unknown as DOMRect`:jsdom 里 DOMRect 构造不可用,这里给的是等价的可读矩形。 + const full = { + ...rect, + x: rect.left, + y: rect.top, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + toJSON: () => ({}), + } as unknown as DOMRect; + Object.defineProperty(element, 'getBoundingClientRect', { + value: () => full, + configurable: true, + }); +} + +function createCanvas(rect: StubRect): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + stubRect(canvas, rect); + document.body.appendChild(canvas); + return canvas; +} + +function sendInspectAction(action: 'enable' | 'disable'): void { + window.dispatchEvent( + new MessageEvent('message', { + source: window.parent, + data: { type: INSPECT_MESSAGE, action }, + }), + ); +} + +function clickAt(target: Element, clientX: number, clientY: number): void { + target.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + cancelable: true, + clientX, + clientY, + }), + ); +} + +function moveAt(target: Element, clientX: number, clientY: number): void { + target.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX, clientY }), + ); +} + +function selectedSelection(): Record { + const message = posted.find( + (entry) => entry.type === INSPECT_MESSAGE && entry.action === 'selected', + ); + if (!message?.selection) throw new Error('未收到 selected 载荷'); + return message.selection; +} + +function sanitizedSelection(): Record { + const parsed = parseLocalGamePreviewInspectMessage({ + type: INSPECT_MESSAGE, + action: 'selected', + selection: selectedSelection(), + }); + if (parsed?.action !== 'selected') throw new Error('载荷未通过净化'); + return { ...parsed.selection }; +} + +beforeAll(() => { + window.requestAnimationFrame = (() => + 0) as typeof window.requestAnimationFrame; + window.setInterval = (() => 0) as unknown as typeof window.setInterval; + vi.spyOn(window, 'postMessage').mockImplementation(((message: unknown) => { + // 桥脚本自己的协议载荷,形状由上面的 BridgePayload 契约保证。 + const payload = message as BridgePayload; + posted.push(payload); + }) as unknown as typeof window.postMessage); + // 桥脚本按字符串随包,这里执行的就是 `include_str!` 的那份代码。 + // eslint-disable-next-line no-new-func + new Function(BRIDGE_SOURCE)(); +}); + +afterAll(() => { + vi.restoreAllMocks(); +}); + +beforeEach(() => { + posted.length = 0; + document.body.innerHTML = ''; + delete bridgeWindow.__GENARRATIVE_PREVIEW_GAME__; +}); + +describe('运行画面点选:Phaser 4 档', () => { + it('setInteractive 命中时走 hitTestPointer,身份取显式标注,矩形取对象屏幕矩形', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const sprite = createSprite({ + bounds: { left: 100, top: 50, width: 200, height: 100 }, + data: { + genarrativeResourceId: 'local-asset:sprite', + genarrativeResourcePath: 'assets/hero.png', + }, + textureKey: 'hero-tex', + }); + const scene = createPhaserScene({ + active: true, + interactiveHits: [sprite], + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'phaser', + game: createPhaserGame({ + canvas, + pointer: { x: 150, y: 90 }, + scenes: [scene], + }), + }; + + sendInspectAction('enable'); + clickAt(canvas, 150, 90); + + expect(selectedSelection()).toEqual({ + label: 'hero', + elementTag: 'canvas', + elementRole: '', + text: '', + width: 200, + height: 100, + resourceIds: ['local-asset:sprite'], + sourcePath: '/assets/hero.png', + }); + // 同一载荷过了宿主侧净化后仍然保住身份(id 不含空白与路径分隔符,sourcePath 取 pathname)。 + expect(sanitizedSelection()).toEqual({ + label: 'hero', + elementTag: 'canvas', + elementRole: undefined, + text: undefined, + width: 200, + height: 100, + resourceIds: ['local-asset:sprite'], + sourcePath: '/assets/hero.png', + }); + }); + + it('未开 setInteractive 的精灵由 getBounds 与指针世界坐标求交兜底', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const sprite = createSprite({ + bounds: { left: 320, top: 200, width: 160, height: 80 }, + data: { genarrativeResourcePath: 'assets/props/barrel.png' }, + }); + const scene = createPhaserScene({ + active: true, + interactiveHits: [], + children: [sprite], + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'phaser', + game: createPhaserGame({ + canvas, + pointer: { x: 400, y: 240 }, + scenes: [scene], + }), + }; + + sendInspectAction('enable'); + clickAt(canvas, 400, 240); + + const selection = selectedSelection(); + expect(selection.label).toBe('barrel'); + expect(selection.resourceIds).toEqual([]); + expect(selection.sourcePath).toBe('/assets/props/barrel.png'); + expect(sanitizedSelection()).toMatchObject({ + label: 'barrel', + resourceIds: [], + sourcePath: '/assets/props/barrel.png', + width: 160, + height: 80, + }); + }); + + it('多场景只取激活场景:非激活场景的命中不参与', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const inactiveSprite = createSprite({ + bounds: { left: 0, top: 0, width: 100, height: 100 }, + data: { genarrativeResourceId: 'local-asset:inactive' }, + }); + const inactiveScene = createPhaserScene({ + active: false, + interactiveHits: [inactiveSprite], + }); + const activeScene = createPhaserScene({ + active: true, + interactiveHits: [], + children: [], + }); + const game = createPhaserGame({ + canvas, + pointer: { x: 20, y: 20 }, + scenes: [inactiveScene, activeScene], + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { engine: 'phaser', game }; + + sendInspectAction('enable'); + clickAt(canvas, 20, 20); + + expect(selectedSelection().resourceIds).toEqual([]); + expect(selectedSelection().label).toBe('canvas'); + + // 激活场景自己命中时仍然取激活场景的对象。 + const activeSprite = createSprite({ + bounds: { left: 10, top: 10, width: 60, height: 60 }, + data: { genarrativeResourceId: 'local-asset:active' }, + }); + activeScene.input.hitTestPointer = () => [activeSprite]; + sendInspectAction('enable'); + posted.length = 0; + clickAt(canvas, 30, 30); + + expect(selectedSelection().resourceIds).toEqual(['local-asset:active']); + }); + + it('无显式标注时身份回落到贴图 URL,标签取文件名去扩展名', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const sprite = createSprite({ + bounds: { left: 0, top: 0, width: 100, height: 100 }, + textureKey: 'tiles', + sourceImage: { + src: 'http://localhost:3000/assets/atlas/sky-tile.png?v=3', + }, + }); + const scene = createPhaserScene({ + active: true, + interactiveHits: [sprite], + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'phaser', + game: createPhaserGame({ + canvas, + pointer: { x: 50, y: 50 }, + scenes: [scene], + }), + }; + + sendInspectAction('enable'); + clickAt(canvas, 50, 50); + + expect(selectedSelection()).toMatchObject({ + label: 'sky-tile', + sourcePath: '/assets/atlas/sky-tile.png', + resourceIds: [], + }); + }); + + it('texture.source[].image 的 currentSrc 优先于 src', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const sprite = createSprite({ + bounds: { left: 0, top: 0, width: 100, height: 100 }, + textureKey: 'frames', + textureSource: { + currentSrc: 'http://localhost:3000/assets/atlas/frame-01.png', + src: 'http://localhost:3000/assets/atlas/fallback.png', + }, + }); + const scene = createPhaserScene({ + active: true, + interactiveHits: [sprite], + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'phaser', + game: createPhaserGame({ + canvas, + pointer: { x: 50, y: 50 }, + scenes: [scene], + }), + }; + + sendInspectAction('enable'); + clickAt(canvas, 50, 50); + + expect(selectedSelection()).toMatchObject({ + label: 'frame-01', + sourcePath: '/assets/atlas/frame-01.png', + }); + }); + + it('贴图 URL 也拿不到时标签回落到 texture.key', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const sprite = createSprite({ + bounds: { left: 0, top: 0, width: 100, height: 100 }, + textureKey: 'procedural-sky', + }); + const scene = createPhaserScene({ + active: true, + interactiveHits: [sprite], + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'phaser', + game: createPhaserGame({ + canvas, + pointer: { x: 50, y: 50 }, + scenes: [scene], + }), + }; + + sendInspectAction('enable'); + clickAt(canvas, 50, 50); + + expect(selectedSelection()).toMatchObject({ + label: 'procedural-sky', + sourcePath: '', + resourceIds: [], + }); + }); + + it('相机 matrixCombined 可用时优先走矩阵换算(四角取包围盒)', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const sprite = createSprite({ + bounds: { left: 10, top: 20, width: 100, height: 50 }, + data: { genarrativeResourceId: 'local-asset:scaled' }, + }); + const camera = createPhaserCamera({ + // v4 的 matrixCombined 已经把 scroll/zoom/相机 x/y 全折进去(Camera.preRender 组装), + // 这里用 2 倍缩放矩阵:字段换算会给出未缩放的矩形,只有走矩阵才会是 (200,100,400,200)。 + matrixCombined: { + transformPoint: ( + x: number, + y: number, + out: { x: number; y: number }, + ) => { + out.x = x * 2; + out.y = y * 2; + return out; + }, + }, + }); + const scene = createPhaserScene({ + active: true, + camera, + interactiveHits: [sprite], + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'phaser', + game: createPhaserGame({ + canvas, + pointer: { x: 50, y: 40 }, + scenes: [scene], + }), + }; + + sendInspectAction('enable'); + clickAt(canvas, 50, 40); + + // 世界 (10,20)-(110,70) 经 2 倍矩阵 → 屏幕 (20,40)-(220,140):宽 200、高 100。 + // 字段换算(zoom=1/scroll=0/camera=0)会给 (10,20,100,50),所以这就是「走了矩阵」的证据。 + expect(selectedSelection()).toMatchObject({ + label: 'canvas', + resourceIds: ['local-asset:scaled'], + width: 200, + height: 100, + }); + sendInspectAction('enable'); + moveAt(canvas, 50, 40); + const overlay = document.querySelector( + '[data-genarrative-preview-inspect="true"]', + ); + expect(overlay?.style.left).toBe('20px'); + expect(overlay?.style.top).toBe('40px'); + expect(overlay?.style.width).toBe('200px'); + expect(overlay?.style.height).toBe('100px'); + }); + + it('没有 matrixCombined 时按相机字段换算(含 scroll / zoom / 相机 x,y)', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const sprite = createSprite({ + bounds: { left: 10, top: 20, width: 100, height: 50 }, + data: { genarrativeResourceId: 'local-asset:plain' }, + }); + const camera = createPhaserCamera({ + scrollX: 10, + scrollY: 20, + zoom: 2, + x: 3, + y: 4, + }); + const scene = createPhaserScene({ + active: true, + camera, + interactiveHits: [sprite], + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'phaser', + game: createPhaserGame({ + canvas, + pointer: { x: 50, y: 40 }, + scenes: [scene], + }), + }; + + sendInspectAction('enable'); + clickAt(canvas, 50, 40); + + // 世界 (10,20)-(110,70) 经 origin=0.5 / zoom=2 / scroll=(10,20) / camera=(3,4) + // → 屏幕 (-397,-296)-( -197,-196):宽 200、高 100。 + expect(selectedSelection()).toMatchObject({ + width: 200, + height: 100, + }); + }); + + it('两个重叠对象时命中最上层(hitTestPointer 是按注册顺序返回,不是叠放顺序)', () => { + // 真实 Phaser 4.2.1 实测(Chromium + Phaser.CANVAS,depth 0/10 两个 zone 完全重叠): + // 先注册上层对象时 `hitTestPointer` 返回 ['top-depth10', 'bottom-depth0'],_list 顺序即注册顺序; + // Phaser 自己随后用 InputPlugin.sortGameObjects 按 camera.renderList 索引降序排(topOnly 取 [0])。 + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const bottom = createSprite({ + bounds: { left: 100, top: 100, width: 200, height: 200 }, + data: { genarrativeResourceId: 'local-asset:bottom' }, + }); + const top = createSprite({ + bounds: { left: 100, top: 100, width: 200, height: 200 }, + data: { genarrativeResourceId: 'local-asset:top' }, + }); + const camera = createPhaserCamera({ renderList: [bottom, top] }); + const scene = createPhaserScene({ + active: true, + camera, + interactiveHits: [top, bottom], + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'phaser', + game: createPhaserGame({ + canvas, + pointer: { x: 200, y: 200 }, + scenes: [scene], + }), + }; + + sendInspectAction('enable'); + clickAt(canvas, 200, 200); + + expect(selectedSelection().resourceIds).toEqual(['local-asset:top']); + }); + + it('以本次点击坐标为准:activePointer 停在别处也命中点击位置的对象', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const sprite = createSprite({ + bounds: { left: 600, top: 400, width: 100, height: 100 }, + data: { genarrativeResourceId: 'local-asset:clicked' }, + }); + // 指针停在左上角(最近一次 mousemove 的位置),点击发生在右下角。 + const pointer: FakePointer = { x: 5, y: 5 }; + const scene = createPhaserScene({ + active: true, + interactiveHits: [sprite], + }); + const sceneInputCalls: { x: number; y: number }[] = []; + scene.input.hitTestPointer = (received: FakePointer) => { + sceneInputCalls.push({ x: received.x, y: received.y }); + return received.x === 650 && received.y === 450 ? [sprite] : []; + }; + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'phaser', + game: createPhaserGame({ canvas, pointer, scenes: [scene] }), + }; + + sendInspectAction('enable'); + clickAt(canvas, 650, 450); + + expect(sceneInputCalls).toEqual([{ x: 650, y: 450 }]); + expect(selectedSelection().resourceIds).toEqual(['local-asset:clicked']); + // 覆写只限 hitTest 期间:结束后 activePointer(含 worldX/worldY/camera)还原。 + expect(pointer).toEqual({ + x: 5, + y: 5, + worldX: undefined, + worldY: undefined, + camera: undefined, + }); + }); + + it('activePointer 缺失时退化为几何兜底,不抛错', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const sprite = createSprite({ + bounds: { left: 300, top: 300, width: 100, height: 100 }, + data: { genarrativeResourceId: 'local-asset:no-pointer' }, + }); + const scene = createPhaserScene({ + active: true, + interactiveHits: [], + children: [sprite], + }); + // 没有 activePointer(例如输入子系统未就绪)时只走几何兜底。 + const game = createPhaserGame({ canvas, pointer: null, scenes: [scene] }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { engine: 'phaser', game }; + + sendInspectAction('enable'); + expect(() => clickAt(canvas, 350, 350)).not.toThrow(); + + expect(selectedSelection().resourceIds).toEqual(['local-asset:no-pointer']); + }); + + it('移动高亮跟随命中对象的屏幕矩形,换算不稳时退化为指针点矩形', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const sprite = createSprite({ + bounds: { left: 200, top: 120, width: 300, height: 180 }, + data: { genarrativeResourceId: 'local-asset:hero' }, + }); + const scene = createPhaserScene({ + active: true, + interactiveHits: [sprite], + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'phaser', + game: createPhaserGame({ + canvas, + pointer: { x: 250, y: 200 }, + scenes: [scene], + }), + }; + + sendInspectAction('enable'); + moveAt(canvas, 250, 200); + const overlay = document.querySelector( + '[data-genarrative-preview-inspect="true"]', + ); + expect(overlay?.style.left).toBe('200px'); + expect(overlay?.style.top).toBe('120px'); + expect(overlay?.style.width).toBe('300px'); + expect(overlay?.style.height).toBe('180px'); + + // 相机字段不可用(旋转)时不阻塞:矩形退化成 pointer 点的 1x1。 + scene.cameras.main.rotation = 0.5; + moveAt(canvas, 400, 300); + const degraded = document.querySelector( + '[data-genarrative-preview-inspect="true"]', + ); + expect(degraded?.style.left).toBe('400px'); + expect(degraded?.style.top).toBe('300px'); + expect(degraded?.style.width).toBe('1px'); + expect(degraded?.style.height).toBe('1px'); + }); +}); + +describe('运行画面点选:three.js 档', () => { + it('贴图 Mesh 命中:NDC 归一化正确,矩形由 Box3 八角投影给出', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const mesh = createThreeMesh({ + material: { + map: { image: { src: 'http://localhost:3000/assets/hero-mesh.png' } }, + }, + box: { left: 100, top: 50, width: 200, height: 100 }, + }); + const camera = { name: 'camera' }; + const three = createThreeNamespace({ + hits: [{ object: mesh, distance: 1 }], + projection, + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + THREE: three, + scene: { children: [mesh] }, + camera, + renderer: { domElement: canvas }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 200, 200); + + expect(threeProbe.ndc).toEqual({ x: -0.5, y: 0.33333333333333337 }); + expect(threeProbe.camera).toBe(camera); + expect(threeProbe.objects).toEqual([mesh]); + expect(threeProbe.recursive).toBe(true); + expect(selectedSelection()).toEqual({ + label: 'hero-mesh', + elementTag: 'canvas', + elementRole: '', + text: '', + width: 200, + height: 100, + resourceIds: [], + sourcePath: '/assets/hero-mesh.png', + }); + expect(sanitizedSelection()).toMatchObject({ + label: 'hero-mesh', + sourcePath: '/assets/hero-mesh.png', + resourceIds: [], + }); + }); + + it('userData.genarrativeResourceId 优先于贴图 URL,且贴图 URL 不做资源 id', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const mesh = createThreeMesh({ + userData: { genarrativeResourceId: 'local-asset:mesh' }, + material: { + map: { + image: { src: 'http://localhost:3000/assets/from-texture.png' }, + }, + }, + box: { left: 100, top: 50, width: 200, height: 100 }, + }); + const three = createThreeNamespace({ + hits: [{ object: mesh, distance: 1 }], + projection, + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + THREE: three, + scene: { children: [mesh] }, + camera: {}, + renderer: { domElement: canvas }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 200, 200); + + expect(selectedSelection().resourceIds).toEqual(['local-asset:mesh']); + expect(selectedSelection().sourcePath).toBe('/assets/from-texture.png'); + expect(sanitizedSelection()).toMatchObject({ + resourceIds: ['local-asset:mesh'], + sourcePath: '/assets/from-texture.png', + }); + }); + + it('intersectObjects 返回多条时取最近的一条', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const near = createThreeMesh({ + userData: { genarrativeResourceId: 'local-asset:near' }, + box: { left: 0, top: 0, width: 100, height: 100 }, + }); + const far = createThreeMesh({ + userData: { genarrativeResourceId: 'local-asset:far' }, + box: { left: 0, top: 0, width: 100, height: 100 }, + }); + const three = createThreeNamespace({ + hits: [ + { object: near, distance: 1 }, + { object: far, distance: 9 }, + ], + projection, + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + THREE: three, + scene: { children: [far, near] }, + camera: {}, + renderer: { domElement: canvas }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 50, 50); + + expect(selectedSelection().resourceIds).toEqual(['local-asset:near']); + }); + + it('userData.genarrativeResourcePath 优先于贴图 URL 派生标签', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const mesh = createThreeMesh({ + userData: { genarrativeResourcePath: 'assets/models/crate.glb' }, + material: { + map: { + image: { src: 'http://localhost:3000/assets/from-texture.png' }, + }, + }, + box: { left: 0, top: 0, width: 100, height: 100 }, + }); + const three = createThreeNamespace({ + hits: [{ object: mesh, distance: 1 }], + projection, + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + THREE: three, + scene: { children: [mesh] }, + camera: {}, + renderer: { domElement: canvas }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 50, 50); + + expect(selectedSelection()).toMatchObject({ + label: 'crate', + sourcePath: '/assets/models/crate.glb', + resourceIds: [], + }); + }); + + it('贴图只有 texture.source.data 时也能派生源路径与标签', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const mesh = createThreeMesh({ + material: { + map: { + source: { + data: { + currentSrc: 'http://localhost:3000/assets/atlas/floor.png', + }, + }, + }, + }, + box: { left: 0, top: 0, width: 100, height: 100 }, + }); + const three = createThreeNamespace({ + hits: [{ object: mesh, distance: 1 }], + projection, + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + THREE: three, + scene: { children: [mesh] }, + camera: {}, + renderer: { domElement: canvas }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 50, 50); + + expect(selectedSelection()).toMatchObject({ + label: 'floor', + sourcePath: '/assets/atlas/floor.png', + resourceIds: [], + }); + }); + + it('贴图 URL 是 blob:/data: 时不塞进 sourcePath,标签仍不为空', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const mesh = createThreeMesh({ + material: { + map: { image: { src: 'blob:http://localhost:3000/9f1c-uuid' } }, + }, + box: { left: 0, top: 0, width: 100, height: 100 }, + }); + const three = createThreeNamespace({ + hits: [{ object: mesh, distance: 1 }], + projection, + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + THREE: three, + scene: { children: [mesh] }, + camera: {}, + renderer: { domElement: canvas }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 50, 50); + + expect(selectedSelection()).toMatchObject({ + label: 'canvas', + sourcePath: '', + resourceIds: [], + }); + }); +}); + +describe('运行画面点选:退化路径', () => { + it('无引擎句柄的 canvas 与 DOM 档一致:标签取元素标签、无身份字段', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + + sendInspectAction('enable'); + clickAt(canvas, 10, 10); + + expect(selectedSelection()).toEqual({ + label: 'canvas', + elementTag: 'canvas', + elementRole: '', + text: '', + width: 800, + height: 600, + resourceIds: [], + sourcePath: '', + }); + expect(sanitizedSelection()).toMatchObject({ + label: 'canvas', + elementTag: 'canvas', + resourceIds: [], + width: 800, + height: 600, + }); + expect(sanitizedSelection().sourcePath).toBeUndefined(); + }); + + it('句柄 engine 未知时不进引擎档,按 DOM 退化', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'babylon', + game: {}, + }; + + sendInspectAction('enable'); + clickAt(canvas, 10, 10); + + expect(selectedSelection()).toMatchObject({ + label: 'canvas', + resourceIds: [], + sourcePath: '', + }); + }); + + it('引擎句柄在位但命中空白:不伪造 id,退化为不带素材的区域引用', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const three = createThreeNamespace({ hits: [], projection }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + THREE: three, + scene: { children: [] }, + camera: {}, + renderer: { domElement: canvas }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 10, 10); + + expect(selectedSelection()).toEqual({ + label: 'canvas', + elementTag: 'canvas', + elementRole: '', + text: '', + width: 800, + height: 600, + resourceIds: [], + sourcePath: '', + }); + }); + + it('three 句柄缺 renderer.domElement 时不抛错,按未命中退化', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const three = createThreeNamespace({ + hits: [{ object: createThreeMesh({}), distance: 1 }], + projection, + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + THREE: three, + scene: { children: [] }, + camera: {}, + renderer: {}, + }; + + sendInspectAction('enable'); + expect(() => clickAt(canvas, 10, 10)).not.toThrow(); + + expect(selectedSelection()).toMatchObject({ + label: 'canvas', + resourceIds: [], + sourcePath: '', + }); + }); + + it('Phaser 句柄缺场景/指针时退化,且点选协议仍然收尾', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { engine: 'phaser', game: {} }; + + sendInspectAction('enable'); + clickAt(canvas, 10, 10); + + expect(posted.map((entry) => entry.action)).toEqual([ + 'enabled', + 'selected', + 'disabled', + ]); + expect(selectedSelection()).toMatchObject({ + label: 'canvas', + resourceIds: [], + }); + }); +}); + +describe('运行画面点选:DOM 档不回归', () => { + it('img 的 src 与资源标识属性逐字保留', () => { + const img = document.createElement('img'); + img.setAttribute('src', '/assets/photo.png'); + img.setAttribute('data-resource-id', 'local-asset:photo'); + document.body.appendChild(img); + + sendInspectAction('enable'); + clickAt(img, 5, 5); + + expect(selectedSelection()).toEqual({ + label: 'img', + elementTag: 'img', + elementRole: '', + text: '', + width: 1, + height: 1, + resourceIds: ['local-asset:photo'], + sourcePath: '/assets/photo.png', + }); + expect(sanitizedSelection()).toMatchObject({ + label: 'img', + elementTag: 'img', + resourceIds: ['local-asset:photo'], + sourcePath: '/assets/photo.png', + }); + }); + + it('资源标识属性三种写法都收,顺序即声明顺序', () => { + const region = document.createElement('div'); + region.setAttribute('data-resource-id', 'local-asset:one'); + region.setAttribute('data-asset-id', 'local-asset:two'); + region.setAttribute('data-genarrative-resource-id', 'local-asset:three'); + document.body.appendChild(region); + + sendInspectAction('enable'); + clickAt(region, 1, 1); + + expect(selectedSelection().resourceIds).toEqual([ + 'local-asset:one', + 'local-asset:two', + 'local-asset:three', + ]); + }); + + it('子节点 img|video|audio 的 src 参与来源路径', () => { + const region = document.createElement('div'); + const img = document.createElement('img'); + img.setAttribute('src', '/assets/inner.png'); + region.appendChild(img); + document.body.appendChild(region); + + sendInspectAction('enable'); + clickAt(region, 1, 1); + + expect(selectedSelection()).toMatchObject({ + label: 'div', + elementTag: 'div', + sourcePath: '/assets/inner.png', + resourceIds: [], + }); + }); + + it('Esc 取消与 disable 的协议不变', () => { + const region = document.createElement('div'); + document.body.appendChild(region); + + sendInspectAction('enable'); + expect(posted.map((entry) => entry.action)).toEqual(['enabled']); + + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }), + ); + expect(posted.map((entry) => entry.action)).toEqual([ + 'enabled', + 'cancelled', + ]); + + sendInspectAction('disable'); + expect(posted.map((entry) => entry.action)).toEqual([ + 'enabled', + 'cancelled', + ]); + }); +}); From 6264fed6b4a0aac32b9c3d46c803c0c000b316b9 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 20:50:26 +0800 Subject: [PATCH 02/14] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E7=82=B9=E9=80=89=E7=BB=93=E6=9E=9C=E6=94=B6=E6=95=9B=E5=88=B0?= =?UTF-8?q?=20manifest=20=E7=8E=B0=E6=9C=89=E7=B4=A0=E6=9D=90=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增匹配纯函数:显式 id、精确路径后缀、文件名、序列帧地址四档,未知值一律丢弃 - 恰好命中一个素材且标签由文件名派生时,芯片名归一化为资源显示名 - 点选处理改为调用该纯函数,派发载荷字段与顺序不变 - 新增匹配与标签归一化的单元用例 --- .../runtimeInspectResourceMatch.ts | 173 +++++++++++ .../src/view/project-development/index.tsx | 31 +- .../tests/runtimeInspectResourceMatch.test.ts | 278 ++++++++++++++++++ 3 files changed, 468 insertions(+), 14 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/runtimeInspectResourceMatch.ts create mode 100644 apps/ai-game-creator-shell/tests/runtimeInspectResourceMatch.test.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/runtimeInspectResourceMatch.ts b/apps/ai-game-creator-shell/src/features/project-workspace/runtimeInspectResourceMatch.ts new file mode 100644 index 000000000..743b54b14 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/runtimeInspectResourceMatch.ts @@ -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); +} 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 50e0c00dc..89274a822 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 @@ -143,6 +143,10 @@ import { resourceLabelResolver, resourceReferenceCategoryLabel, } from '../../features/project-workspace/resourceReferences'; +import { + matchRuntimeInspectResources, + resolveRuntimeInspectLabel, +} from '../../features/project-workspace/runtimeInspectResourceMatch'; import { useElementFullscreen } from '../../features/project-workspace/useElementFullscreen'; import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; import { @@ -2795,28 +2799,27 @@ export default function ProjectDevelopmentView({ }, [embeddedPreviewUrl, mode]); const handleRuntimeInspectSelection = useCallback( (selection: LocalGamePreviewInspectSelection) => { - const sourceName = selection.sourcePath?.split('/').pop()?.toLowerCase(); - const matchedResourceIds = sourceName - ? manifest.assets - .filter( - (asset) => - asset.localPath.split(/[\\/]/u).pop()?.toLowerCase() === - sourceName, - ) - .map((asset) => asset.id) - : []; + const { resourceIds } = matchRuntimeInspectResources({ + assets: manifest.assets, + sourcePath: selection.sourcePath, + resourceIds: selection.resourceIds, + }); dispatchResourceReferenceInsert({ type: 'runtime-region', - label: selection.label, + label: resolveRuntimeInspectLabel({ + assets: manifest.assets, + resourceIds, + sourcePath: selection.sourcePath, + label: selection.label, + elementTag: selection.elementTag, + }), runId: preview?.port ? `preview-${preview.port}` : undefined, elementTag: selection.elementTag, elementRole: selection.elementRole, text: selection.text, width: selection.width, height: selection.height, - resourceIds: [ - ...new Set([...selection.resourceIds, ...matchedResourceIds]), - ], + resourceIds, source: 'runtime-picker', }); setRuntimeInspectMode(false); diff --git a/apps/ai-game-creator-shell/tests/runtimeInspectResourceMatch.test.ts b/apps/ai-game-creator-shell/tests/runtimeInspectResourceMatch.test.ts new file mode 100644 index 000000000..b8f4c13b8 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/runtimeInspectResourceMatch.test.ts @@ -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'); + }); +}); From ab518ea574d5c4d936118b22ed7b2caeb5f7b133 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 20:50:33 +0800 Subject: [PATCH 03/14] =?UTF-8?q?AGC=20=E8=B5=B7=E6=AD=A5=E5=B7=A5?= =?UTF-8?q?=E7=A8=8B=E4=B8=8E=E6=96=B0=E5=BB=BA=E8=84=9A=E6=89=8B=E6=9E=B6?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E7=82=B9=E9=80=89=E6=89=80=E9=9C=80=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E5=8F=A5=E6=9F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建项目默认脚本创建 Phaser 实例后发布只读句柄 - Phaser 与 three.js 两个起步模板及空白三维场景模板同样发布句柄 - Phaser 脚手架与模板改用 Image 标签装载图片,让贴图保留原始素材地址 --- apps/ai-game-creator-shell/src-tauri/src/main.rs | 2 +- .../v1/blank-3d-scene/project/game/main.js | 9 +++++++++ .../v1/phaser-2d-starter/project/game/game.js | 7 ++++++- .../v1/threejs-3d-starter/project/game/main.js | 9 +++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 8db190f8d..94cf14b63 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1253,7 +1253,7 @@ const DEFAULT_GAME_INDEX_HTML: &str = r#" "#; 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"; diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/main.js b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/main.js index 73c37324b..2febd40ff 100644 --- a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/main.js +++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/main.js @@ -18,6 +18,15 @@ renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(window.innerWidth, window.innerHeight); container.append(renderer.domElement); +// 发布运行画面实例句柄,供预览桥点选定位场景、相机与渲染器。 +window.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + THREE, + 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)); diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/game.js b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/game.js index a77ba6be9..61574cba8 100644 --- a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/game.js +++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/game.js @@ -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 }; diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/main.js b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/main.js index 211f285ef..dc0ed61ac 100644 --- a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/main.js +++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/main.js @@ -18,6 +18,15 @@ renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(window.innerWidth, window.innerHeight); container.append(renderer.domElement); +// 发布运行画面实例句柄,供预览桥点选定位场景、相机与渲染器。 +window.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + THREE, + 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)); From f729203c6c5479df0c9365d7608a79e0c086345c Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 20:50:44 +0800 Subject: [PATCH 04/14] =?UTF-8?q?=E8=A1=A5=E8=BF=90=E8=A1=8C=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E7=82=B9=E9=80=89=E7=9A=84=E8=A1=8C=E4=B8=BA=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E3=80=81=E8=B8=A9=E5=9D=91=E4=B8=8E=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 功能说明新增运行画面素材点选的行为合同、退化条款与边界(贴图地址不可得、只认显式句柄) - PRD 补点选素材条款与更新时间摘要 - 踩坑记录新增 Phaser 命中顺序与默认 XHR 装载两条 - 新增本里程碑与实施计划文档 --- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 3 +- ...–½计划】AGC运行画面点选引擎适配-2026-10-04.md | 63 +++++++++++++++++++ ...Œ程碑】AGC运行画面点选引擎适配-2026-10-04.md | 53 ++++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 12 ++++ .../【功能说明】AGC聊天素材引用-2026-09-08.md | 33 +++++++++- 5 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md create mode 100644 docs/project-memory/plans/【里程碑】AGC运行画面点选引擎适配-2026-10-04.md diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 9e38ea713..79c93f2a9 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -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 标签上下文,不能把脚本、样式、模板或注释中的 `` / `` 文本误判为结束标签;省略结束标签的 UTF-8 HTML 仍需安全注入。 - 运行视窗右下角提供“全屏预览”:只把游戏画面那一格送进全屏,顶部页签、右侧对话和底部信息栏不跟着放大;再次点击该入口、按 `Esc` 或由宿主退出全屏都回到原布局。宿主没有 Fullscreen API 时整枚入口不渲染,不留点了没反应的按钮。 +- 运行视窗提供“点选素材”:把画面上点中的内容落成一条 `runtime-region` 引用插入对话,只影响这一次引用——不改资源选中、不改信息栏判据、不写 manifest 或版本、不重载预览,也不代表“该素材正在被这个位置使用”。预览页是 Phaser 4 或 three.js 玩法时,点中由**已登记素材**渲染的精灵、图集帧、贴图网格或模型,引用必须带该素材的稳定 id,显示名与其它引用入口一致;玩法没有发布引擎句柄、对象既没有标注也取不到贴图地址、或画面是程序化绘制时,退化为不带素材的区域引用——不报错、不伪造 id、引用照常能插入能发送。 - 运行视窗下方的信息栏只在**有真实内容**时存在(当前判据是**资源选中态**:在资源画布或浮层资源面板里选中一张资源后切到运行页签仍保留,信息栏渲染它的只读字段;运行画面上的“点选素材”只往对话插入引用,不改选中):没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;不显示示例字段、默认数值、未载入控件或功能说明。暂时没有数据源的区域(「数值微调」的登记表)不渲染区域标题与卡片,等编辑态登记表接进来后与内容一起出现。Agent 对话标题栏不显示头像图标,“与陶泥儿的对话”及副标题按标题栏左侧对齐,钱包和审批入口继续位于右侧。 - 数值修改立即写入当前项目的编辑态配置。 diff --git a/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md b/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md new file mode 100644 index 000000000..fce5be70a --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md @@ -0,0 +1,63 @@ +# 【实施计划】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 / 版本绑定 / 素材替换链路、AGC 玩法 Skill 与 Runtime Prompt、模板 zip 发布与线上索引。 +- 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)。 diff --git a/docs/project-memory/plans/【里程碑】AGC运行画面点选引擎适配-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC运行画面点选引擎适配-2026-10-04.md new file mode 100644 index 000000000..02f7d4472 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC运行画面点选引擎适配-2026-10-04.md @@ -0,0 +1,53 @@ +# 【里程碑】AGC 运行画面点选识别 Phaser 4 与 three.js 画面上的已登记素材 + +| 字段 | 值 | +| ----------- | -------------------------------------------------------- | +| Version | 1.0 | +| Status | proposed | +| 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 / Runtime Prompt,并重新发布模板产物(`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 或登录态。 + +## 验收标准 + +- [ ] Phaser 4 起步工程:点中由已登记素材渲染的精灵 / 图集帧,引用芯片显示该素材名,载荷 `resourceIds` 命中该素材 id。 +- [ ] three.js 起步工程:点中贴了已登记贴图的 Mesh(含 GLB 内嵌纹理的模型走显式标注路径),结果同上。 +- [ ] 同一对象同时存在显式素材标注与贴图地址推断时,取显式标注。 +- [ ] 无引擎句柄 / 点中空白 / 程序化绘制三种情况:退化为不带素材的区域引用,零报错、零伪造 id,引用可插入可发送。 +- [ ] 命中对象时高亮为该对象在画面上的区域;取不到对象矩形时高亮范围与 DOM 档一致。 +- [ ] DOM 点选(HTML HUD、媒体 `src`、资源标识属性)行为与现有用例逐字不变。 +- [ ] 起步工程与新建项目默认脚手架发布引擎句柄;新建 Phaser 4 项目不额外改代码即可点选。 + +## 证据要求 + +- 自动化:引擎档识别的 JS 单元用例(jsdom 中加载真实桥脚本 + 假引擎对象)、宿主素材映射纯函数用例、桥注入与协议回归用例、`npm run agc:typecheck`。 +- 运行时:Phaser 4 与 three.js 各一条真机点选 smoke(截图或录制,含引用芯片与载荷证据)。 +- 边界:三种退化情况各一条、净化与数量上限不回归、`resourceIds` 只取 manifest 现有素材。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 20a8f87ba..2075bcd2b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,18 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-04 Phaser 4 `hitTestPointer` 的返回顺序不是叠放顺序 + +- **现象**:运行画面点选 Phaser 4 画面时,按「数组第一个 / 最后一个」当最上层会点错对象——点的是上层精灵,引用却落到下层的素材上;对象越多越容易错。 +- **原因**:`hitTestPointer` 返回的是输入对象的注册 / 内部列表顺序,与真实叠放无关;叠放由命中相机 `renderList` 的索引决定(含 depth 与入序)。 +- **结论(现行口径)**:点选命中要按 `pointer.camera.renderList` 的索引取最上层;几何兜底也必须按 depth + 入序排序,不能沿用注册顺序取值。 + +## 2026-10-04 Phaser 4 默认 XHR 装载下贴图元素是 blob 地址 + +- **现象**:运行画面点选拿不到原始素材路径——贴图元素上的地址是会话内的 blob 地址,按文件名反推素材会得到无意义的临时名。 +- **原因**:Phaser 4 默认用 XHR 装载图片,地址只在当前会话内有效,不携带仓库 / manifest 里的路径信息。 +- **结论(现行口径)**:Phaser 4 项目的素材身份必须靠玩法显式标注(`setData`)发布,不要试图从 blob 地址反推文件名;没有标注就退化为不带素材的区域引用。与主规范「运行画面素材点选(2026-10-04)」的边界一致。 + ## 2026-10-03 AGC 画布「引用」死按钮:window 事件的消费者挂在一个只在另一条链路赋值的 ref 上 - **现象**(Issue 602):AGC 资源画布选中一张已登记素材,选中工具条点「引用」(图标 `@`、可见文案与 `title` 都是「引用」)没有任何反应——聊天输入框里不出现 `@素材名` 芯片,也没有任何提示。普通项目(`directProjectMode`)必现,立项策划项目(`planningStartMode`)复现不出来;把素材卡拖到对话栏的批量引用同样没反应。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 6718d6e2c..a654238bd 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -1,6 +1,6 @@ # AGC 聊天素材引用 -更新时间:2026-10-03 +更新时间:2026-10-04 AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,并提供 Codex 风格的 Skill 提及。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;输入 `$` 会按当前 DirectProject 可用 Skill 名称过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。 @@ -53,6 +53,37 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, 输入校验按整条消息判断是否有内容:每个 `input_text` 片段都允许是空字符串、空格或换行,不逐片段拒绝,也不合并、删除或改写片段;原始文字、分段和 `content[]` 顺序保持不变。整条消息必须至少包含一段非空白文字,或至少一个非文本 part(素材引用 / 运行画面引用 / Skill 引用 / 附件引用),否则返回“聊天内容不能为空”。各类引用继续执行原有字段、数量、manifest 归属和路径安全校验;即使消息同时带有正文,非法引用也必须拒绝,不能由正文绕过。 +## 运行画面素材点选(2026-10-04) + +「点选素材」把运行画面里点中的内容落成一条 `runtime-region` 引用插入对话。点选只影响这一次引用:不改资源选中、不改信息栏判据、不写 manifest 或版本、不重载预览。 + +### 必须成立的行为 + +1. **HTML 区域**:点中预览页里的 DOM 元素时,语义保持 2026-09 起的能力——元素矩形、元素上的资源标识属性或媒体 `src` 参与引用身份,行为逐字不变。 +2. **引擎画面**:预览页是 Phaser 4 或 three.js 的 canvas 玩法时,点中由**已登记项目素材**渲染的精灵、图集帧、贴图网格或模型,引用必须带该素材的稳定 id;同一素材在对话里的显示名与其它引用入口(`@` 候选、资源卡「引用」、拖拽批量引用)完全一致。 +3. **身份来源与优先级**:玩法在渲染对象上显式标注的素材身份优先于从贴图地址推断出的文件名;两者都取不到时按第 4 条退化。推断结果必须与 manifest 现有资产对得上——宁可不成引用,也不伪造 id。 +4. **无法确定素材时退化**:画面为程序化绘制、对象没有标注也取不到贴图地址、预览页没有发布引擎句柄——全部退化为**不带素材**的区域引用,与今天的 DOM 退化语义一致:不报错、不静默丢弃,引用仍能插入、能发送;Rust 对未知素材 id 的既有拒绝口径不变。 +5. **命中反馈**:能定位到命中对象时,高亮范围是命中对象在画面上的区域;取不到对象矩形时退化为与 DOM 档一致的高亮范围。 +6. **协议与净化不变**:运行画面点选与 DOM 点选共用同一套预览消息协议、同一套字段净化与数量上限;引擎侧取到的标识必须先过同一套净化才能进入引用载荷。 + +### 边界与非目标 + +- 支持范围是 Phaser 4 与 three.js 两条 AGC 主力链路;Cocos Creator、Godot、Unity 走各自编辑器桥接,不在本能力范围内。 +- 玩法必须先发布一个只读的引擎句柄,点选才能进入引擎档;未发布的项目按第 4 条退化。**改动前生成的历史项目不会自动具备该句柄**,需要重新生成或人工补充——能力说明里必须如实标注,不承诺历史项目可用。 +- 不引入运行期资源使用关系的持久化:不记录"某素材在画面上某位置被用过",不注入玩法代码、不写资源侧车、不改 manifest。 +- 不做像素级或颜色拾取,不为了识别素材去改写玩法源码、资源文件或渲染结果。 +- **Phaser 4 默认装载下贴图地址不可得**:Phaser 4 默认用 XHR 装载图片,贴图元素上的地址是会话内的临时地址,读不回原始素材路径。这类项目**必须由玩法显式标注素材身份**才能点出素材;没有标注就按第 4 条退化(退化为不带素材的区域引用),不猜。 +- **只认玩法发布的只读引擎句柄**:预览页没有发布该句柄、或句柄声明的引擎类型不在支持范围内时,一律退化为不带素材的区域引用;宿主不通过扫描全局对象去猜引擎。 + +### 验收标准与证据 + +| 条款 | 验收方式 | 证据 | +| ---- | -------- | ---- | +| 2、3 | Phaser 4 与 three.js 各一条真机 smoke:起步工程 + 一张已登记素材,点中渲染对象后核对引用芯片名与 `resourceIds` | 待补 | +| 1、6 | 现有 DOM 点选与运行视窗用例不回归 | 待补 | +| 4 | 无引擎句柄 / 命中空白 / 程序化绘制三种情况的退化用例 | 待补 | +| 5 | 高亮矩形:声明级用例 + 真机目视 | 待补 | + ## 粘贴解析(2026-09-22) 把含引用 token 的纯文本粘进输入区时,可以逐字命中的 token 会原位变回引用芯片: From df76a665d4fd409bab2c5918d76db88cc2033f52 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 21:02:50 +0800 Subject: [PATCH 05/14] =?UTF-8?q?=E8=A1=A5=E8=BF=90=E8=A1=8C=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E7=82=B9=E9=80=89=E9=AA=8C=E6=94=B6=E8=AF=81=E6=8D=AE?= =?UTF-8?q?=E4=B8=8E=E9=87=8C=E7=A8=8B=E7=A2=91=E5=8B=BE=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 功能说明的验收证据表填入自动化与真实浏览器实测结果 - 里程碑验收标准按证据逐条勾选并补未覆盖项 - 实施计划补四个提交的落盘记录 --- ...–½计划】AGC运行画面点选引擎适配-2026-10-04.md | 1 + ...Œ程碑】AGC运行画面点选引擎适配-2026-10-04.md | 24 ++++++++++++------- .../【功能说明】AGC聊天素材引用-2026-09-08.md | 10 ++++---- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md b/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md index fce5be70a..5f6680636 100644 --- a/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md @@ -61,3 +61,4 @@ - 宿主匹配抽成纯函数 `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(规范与踩坑);验收证据见里程碑文档。 diff --git a/docs/project-memory/plans/【里程碑】AGC运行画面点选引擎适配-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC运行画面点选引擎适配-2026-10-04.md index 02f7d4472..406796a10 100644 --- a/docs/project-memory/plans/【里程碑】AGC运行画面点选引擎适配-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC运行画面点选引擎适配-2026-10-04.md @@ -3,7 +3,7 @@ | 字段 | 值 | | ----------- | -------------------------------------------------------- | | Version | 1.0 | -| Status | proposed | +| 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 | @@ -38,16 +38,24 @@ ## 验收标准 -- [ ] Phaser 4 起步工程:点中由已登记素材渲染的精灵 / 图集帧,引用芯片显示该素材名,载荷 `resourceIds` 命中该素材 id。 -- [ ] three.js 起步工程:点中贴了已登记贴图的 Mesh(含 GLB 内嵌纹理的模型走显式标注路径),结果同上。 -- [ ] 同一对象同时存在显式素材标注与贴图地址推断时,取显式标注。 -- [ ] 无引擎句柄 / 点中空白 / 程序化绘制三种情况:退化为不带素材的区域引用,零报错、零伪造 id,引用可插入可发送。 -- [ ] 命中对象时高亮为该对象在画面上的区域;取不到对象矩形时高亮范围与 DOM 档一致。 -- [ ] DOM 点选(HTML HUD、媒体 `src`、资源标识属性)行为与现有用例逐字不变。 -- [ ] 起步工程与新建项目默认脚手架发布引擎句柄;新建 Phaser 4 项目不额外改代码即可点选。 +- [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` 27 例;浏览器重叠位取最上层且显式 id 胜出 `["enemy-tag"]`) +- [x] 无引擎句柄 / 点中空白 / 程序化绘制三种情况:退化为不带素材的区域引用,零报错、零伪造 id,引用可插入可发送。(`runtimeInspectEngines.test.ts` 27 例;浏览器点空白 `@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`(27)+ `runtimeInspectResourceMatch.test.ts`(11)+ `localGamePreviewFrame.test.ts`(14)+ `resourceReferenceInput.test.tsx`(43)= 95 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)、高亮真机目视。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index a654238bd..ab163fb6e 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -79,10 +79,12 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, | 条款 | 验收方式 | 证据 | | ---- | -------- | ---- | -| 2、3 | Phaser 4 与 three.js 各一条真机 smoke:起步工程 + 一张已登记素材,点中渲染对象后核对引用芯片名与 `resourceIds` | 待补 | -| 1、6 | 现有 DOM 点选与运行视窗用例不回归 | 待补 | -| 4 | 无引擎句柄 / 命中空白 / 程序化绘制三种情况的退化用例 | 待补 | -| 5 | 高亮矩形:声明级用例 + 真机目视 | 待补 | +| 2、3 | Phaser 4 与 three.js 各一条真机 smoke:起步工程 + 一张已登记素材,点中渲染对象后核对引用芯片名与 `resourceIds` | 真实 Chromium 端到端(真实 Phaser 4.2.1 / three 0.184;Phaser 分别用 `loader.imageLoadType='HTMLImageElement'` 与默认 XHR 两种装载):已登记贴图对象得 `resourceIds=["hero"]`、显式标注对象得 `["enemy-tag"]`、非交互精灵走几何兜底也命中;被测桥脚本 sha256 `6276ac48…917a`。**Tauri 客户端链路未跑**(预览服务器注入与路由由 Rust 定向测试覆盖)。 | +| 1、6 | 现有 DOM 点选与运行视窗用例不回归 | `tests/localGamePreviewFrame.test.ts`(14 例)、`tests/resourceReferenceInput.test.tsx`(43 例)、`tests/appSurface.test.ts`(197 passed / 9 skipped)不回归;点选载荷仍走同一套净化与消息版本号。 | +| 4 | 无引擎句柄 / 命中空白 / 程序化绘制三种情况的退化用例 | `tests/runtimeInspectEngines.test.ts`(27 例)覆盖无句柄 / 点空白 / 缺 `renderer.domElement` 三种退化;浏览器点空白得 `@canvas` 且 `resourceIds=[]`、零报错。 | +| 5 | 高亮矩形:声明级用例 + 真机目视 | 浏览器实测高亮矩形与期望逐值相减:Phaser 差 0px、three 最大差 ≈0.0006px;DOM 档仍是元素矩形。 | + +素材收敛口径由 `tests/runtimeInspectResourceMatch.test.ts`(11 例)覆盖:显式 id、精确后缀、文件名、序列帧四档与去重。 ## 粘贴解析(2026-09-22) From 0aed0faa9af7e234b8c5dcffd5d16e6cb6aa0c7f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 23:54:37 +0800 Subject: [PATCH 06/14] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E7=82=B9=E9=80=89=EF=BC=9A=E7=94=9F=E6=88=90=E6=9C=9F=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E8=90=BD=E8=BF=9B=20AGC=20=E7=8E=A9=E6=B3=95=20Skill?= =?UTF-8?q?=EF=BC=88=E9=98=B6=E6=AE=B5=E4=BA=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agc-web-game-development/SKILL.md 新增「运行画面点选契约」:发布只读引擎句柄、Phaser 图片用 Image 标签装载、素材渲染对象标注 manifest 身份、素材进 dist 走与 localPath 对齐的稳定路径 - game-quality-checklist.md 与 agc-game-production-workflow/SKILL.md 只补指针,不在副本里复制契约 - skill-pack:sync 更新 manifest version=2026-08-26.38 与 agc-web-game-development、agc-game-production-workflow 的 sha256(未新增 skill 文件、未改 Rust) - 文档:功能说明边界补生成期契约来源;里程碑把「生成期契约另立里程碑」改为同一 PR 阶段二;实施计划新增「阶段二进展」 --- .../agc-game-production-workflow/SKILL.md | 2 +- .../agc-skills/agc-web-game-development/SKILL.md | 15 +++++++++++++++ .../references/game-quality-checklist.md | 1 + .../src-tauri/resources/agc-skills/manifest.json | 6 +++--- ...实施计划】AGC运行画面点选引擎适配-2026-10-04.md | 12 +++++++++++- ...€�里程碑】AGC运行画面点选引擎适配-2026-10-04.md | 2 +- docs/【功能说明】AGC聊天素材引用-2026-09-08.md | 1 + 7 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/SKILL.md index 77ee538c0..48af3d09f 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/SKILL.md @@ -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** — After the needed implementation, record the build through `agc_run_validation` with `purpose=build`, `program=npm`, `arguments=["run","build"]` and the package `cwd` (`game` for a new Web project). Confirm the actual playable entry under `dist` and fix build or asset-loading failures before preview. Use `purpose=test` for a declared focused test. A successful raw shell command does not replace the recorded `agc_run_validation` build result. 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. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md index 7fef9c8c8..89b0088de 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md @@ -27,6 +27,21 @@ 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, scene, camera, renderer };`(`THREE` 是 `import * as THREE from 'three'` 的命名空间,点选要靠它做射线与包围盒) +2. **Phaser 图片用 Image 标签装载**:游戏配置固定 `loader: { imageLoadType: 'HTMLImageElement' }`。默认的 XHR 装载把贴图地址换成会话内临时地址,点选读不回原始素材路径;这类项目只能靠第 3 条的显式标注。 +3. **给渲染对象标注素材身份**:用已登记素材渲染的对象要带上 manifest 的稳定身份,值只取 `agc_list_registered_assets` 返回的 `localAssetId` / `localPath`,不自行编造、不改写文件名。 + - Phaser:`sprite.setData('genarrativeResourceId', '')`,或 `sprite.setData('genarrativeResourcePath', '')`;两者都给时 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-.js`, read that file instead; its embedded documentation explains how to use it. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md index f352481b0..885affe0e 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md @@ -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「运行画面点选契约」)。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 12678da03..028881a46 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.37", + "version": "2026-08-26.38", "skills": [ { "name": "agc-unity-editor", @@ -59,7 +59,7 @@ "agents/openai.yaml", "references/workflow-contract.md" ], - "sha256": "6c3dac5d6ad693cd854347dbc2c202eb2c6ebed336468daa4ccfb55c08698e0b" + "sha256": "5672e5f931464a90b26bf82e68e1c80577ce3efb29cc0c3234a93c4c71e5769d" }, { "name": "agc-project-structure", @@ -121,7 +121,7 @@ "agents/openai.yaml", "references/game-quality-checklist.md" ], - "sha256": "855803443e6b76e4271df1b4207c8836a7634438814d61e66da08ed27a44ae8c" + "sha256": "55fb4e14fba3682028b4508ac1721a907d1196cf7527113ebb4977d6025d0cdc" }, { "name": "agc-browser-playtest", diff --git a/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md b/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md index 5f6680636..49262159d 100644 --- a/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md @@ -22,7 +22,7 @@ 明确不修改: - `runtime-region` 引用契约、`genarrative.local-preview-inspect.v1` 消息版本号、`LocalGamePreviewFrame.tsx` 的解析与净化、Rust `direct_codex_user_item` 的校验口径(不新增字段;若实测必须加字段,单独评审)。 -- `packages/shared`、manifest / 版本绑定 / 素材替换链路、AGC 玩法 Skill 与 Runtime Prompt、模板 zip 发布与线上索引。 +- `packages/shared`、manifest / 版本绑定 / 素材替换链路、Runtime Prompt 文案、模板 zip 发布与线上索引(AGC 玩法 Skill 的生成期契约属阶段二,见下 `## 阶段二进展`)。 - DOM 点选语义与 `enabled / disabled / cancelled` 协议。 ## 实现顺序 @@ -62,3 +62,13 @@ - 脚手架与三个起步模板发布只读引擎句柄:`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, scene, camera, renderer }`)、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`)、宿主匹配、脚手架与模板源均未改;未合并主线。 diff --git a/docs/project-memory/plans/【里程碑】AGC运行画面点选引擎适配-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC运行画面点选引擎适配-2026-10-04.md index 406796a10..fe565a475 100644 --- a/docs/project-memory/plans/【里程碑】AGC运行画面点选引擎适配-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC运行画面点选引擎适配-2026-10-04.md @@ -23,7 +23,7 @@ ## 不在范围内 -- 生成期契约落位:把句柄发布与对象素材标注写进 AGC 玩法 Skill / Runtime Prompt,并重新发布模板产物(`template.zip` 与线上索引)——另立里程碑。 +- 生成期契约落位:把句柄发布与对象素材标注写进 AGC 玩法 Skill(**同一 PR 的阶段二**,见实施计划 `## 阶段二进展`);重新发布模板产物(`template.zip` 与线上索引)仍不在本次范围内。 - 运行期资源使用关系持久化(不记录"某素材在某位置被用过",不注入代码、不写侧车)。 - Cocos Creator / Godot / Unity 的点选;纯 2D canvas 引擎与 Babylon.js。 - `runtime-region` 引用契约、预览消息版本号、Rust `resourceIds` 校验、资源选中与信息栏判据的任何变化。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index ab163fb6e..a53cd241e 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -74,6 +74,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 不做像素级或颜色拾取,不为了识别素材去改写玩法源码、资源文件或渲染结果。 - **Phaser 4 默认装载下贴图地址不可得**:Phaser 4 默认用 XHR 装载图片,贴图元素上的地址是会话内的临时地址,读不回原始素材路径。这类项目**必须由玩法显式标注素材身份**才能点出素材;没有标注就按第 4 条退化(退化为不带素材的区域引用),不猜。 - **只认玩法发布的只读引擎句柄**:预览页没有发布该句柄、或句柄声明的引擎类型不在支持范围内时,一律退化为不带素材的区域引用;宿主不通过扫描全局对象去猜引擎。 +- **生成期契约落在 AGC 玩法 Skill**:句柄发布、Phaser 图片的 Image 装载、素材渲染对象的身份标注、素材在 dist 的稳定路径这四条写进 `agc-web-game-development`(「运行画面点选契约」,本里程碑阶段二,同一 PR 落位),生成期即按契约产出;本能力说明不承诺历史项目自动具备,也不改写既有项目。 ### 验收标准与证据 From 7d205c8da0015f7681751227cc2290eaf1eb6756 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 00:01:59 +0800 Subject: [PATCH 07/14] =?UTF-8?q?=E5=8E=BB=E6=8E=89=E9=95=BF=E6=9C=9F?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E9=87=8C=E7=9A=84=E9=98=B6=E6=AE=B5=E6=80=A7?= =?UTF-8?q?=E6=8E=AA=E8=BE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 功能说明「边界与非目标」的生成期契约条目:删掉「本里程碑阶段二,同一 PR 落位」这类开发过程话,保留可长期成立的口径 --- docs/【功能说明】AGC聊天素材引用-2026-09-08.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index a53cd241e..e55799474 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -74,7 +74,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 不做像素级或颜色拾取,不为了识别素材去改写玩法源码、资源文件或渲染结果。 - **Phaser 4 默认装载下贴图地址不可得**:Phaser 4 默认用 XHR 装载图片,贴图元素上的地址是会话内的临时地址,读不回原始素材路径。这类项目**必须由玩法显式标注素材身份**才能点出素材;没有标注就按第 4 条退化(退化为不带素材的区域引用),不猜。 - **只认玩法发布的只读引擎句柄**:预览页没有发布该句柄、或句柄声明的引擎类型不在支持范围内时,一律退化为不带素材的区域引用;宿主不通过扫描全局对象去猜引擎。 -- **生成期契约落在 AGC 玩法 Skill**:句柄发布、Phaser 图片的 Image 装载、素材渲染对象的身份标注、素材在 dist 的稳定路径这四条写进 `agc-web-game-development`(「运行画面点选契约」,本里程碑阶段二,同一 PR 落位),生成期即按契约产出;本能力说明不承诺历史项目自动具备,也不改写既有项目。 +- **生成期契约落在 AGC 玩法 Skill**:句柄发布、Phaser 图片的 Image 装载、素材渲染对象的身份标注、素材在 dist 的稳定路径这四条写进 `agc-web-game-development` 的「运行画面点选契约」,生成期即按契约产出;本能力说明不承诺历史项目自动具备,也不改写既有项目。 ### 验收标准与证据 From 19d179bde4a522dfc27ac50e3ff94341b3823aa8 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 15:23:46 +0800 Subject: [PATCH 08/14] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E7=82=B9=E9=80=89=EF=BC=9Athree=20=E5=8F=A5=E6=9F=84=E7=98=A6?= =?UTF-8?q?=E8=BA=AB=20+=20=E5=91=BD=E4=B8=AD=E5=AF=B9=E8=B1=A1=20label=20?= =?UTF-8?q?=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 桥:three 句柄改读 `handle.three || handle.THREE`(旧整命名空间形态仍兼容),四个构造器缺任一即按未命中退化 - 桥:three label 链补 `object.name` → `geometry.type` → `object.type`,Phaser 补 `name` → `type`(取值超 40 字符丢弃);命中对象但取不到素材身份时芯片不再一律是 `@canvas` - 契约:agc-web-game-development 的「运行画面点选契约」改用瘦身句柄 `three: { Raycaster, Vector2, Vector3, Box3 }`,并写明整命名空间会破坏 tree-shaking(同一 Vite 构建 517,682 B → 730,831 B,+41%);skill-pack 指纹同步到 2026-08-26.39 - 模板:threejs-3d-starter 与 blank-3d-scene 的句柄源改成瘦身形态 - 测试:runtimeInspectEngines 新增 6 例(瘦身句柄命中、旧命名空间兼容、name / geometry.type / object.type 兜底、缺构造器退化),共 33 例 - 文档:功能说明补 label 兜底与瘦身句柄的验收行和边界;pitfalls 记一条 tree-shaking 口径;实施计划阶段二进展补该轮结论 --- .../agc-web-game-development/SKILL.md | 5 +- .../resources/agc-skills/manifest.json | 4 +- .../resources/preview/local-preview-fit.js | 35 ++- .../v1/blank-3d-scene/project/game/main.js | 8 +- .../threejs-3d-starter/project/game/main.js | 8 +- .../tests/runtimeInspectEngines.test.ts | 232 ++++++++++++++++++ ...–½计划】AGC运行画面点选引擎适配-2026-10-04.md | 4 +- ...Œ程碑】AGC运行画面点选引擎适配-2026-10-04.md | 6 +- docs/project-memory/shared-memory/pitfalls.md | 6 + .../【功能说明】AGC聊天素材引用-2026-09-08.md | 4 +- 10 files changed, 299 insertions(+), 13 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md index 89b0088de..8f8c37647 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md @@ -33,7 +33,10 @@ When implementing a new game loop or a broad gameplay revision, read `references 1. **发布只读引擎句柄**:引擎实例创建后发布一次,只服务点选与调试,不承载业务状态。句柄缺失或 `engine` 不是 `phaser` / `three` 时点选按第 4 条退化。 - Phaser:`window.__GENARRATIVE_PREVIEW_GAME__ = { engine: 'phaser', game };` - - three.js:`window.__GENARRATIVE_PREVIEW_GAME__ = { engine: 'three', THREE, scene, camera, renderer };`(`THREE` 是 `import * as THREE from 'three'` 的命名空间,点选要靠它做射线与包围盒) + - 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`,不自行编造、不改写文件名。 - Phaser:`sprite.setData('genarrativeResourceId', '')`,或 `sprite.setData('genarrativeResourcePath', '')`;两者都给时 id 优先。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 028881a46..3b67e87e9 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.38", + "version": "2026-08-26.39", "skills": [ { "name": "agc-unity-editor", @@ -121,7 +121,7 @@ "agents/openai.yaml", "references/game-quality-checklist.md" ], - "sha256": "55fb4e14fba3682028b4508ac1721a907d1196cf7527113ebb4977d6025d0cdc" + "sha256": "d92394d7bfc660fa436c0753822c8f2da12c57b04e091bbf065f471ce27d6cc9" }, { "name": "agc-browser-playtest", diff --git a/apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js b/apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js index ade83b6c9..5ff19cd48 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js +++ b/apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js @@ -478,7 +478,7 @@ return ''; } }; - // 标签链:显式路径文件名去扩展名 → 贴图 URL 同样派生 → Phaser texture.key → 'canvas'。 + // 标签链:显式路径文件名去扩展名 → 贴图 URL 同样派生 → 引擎侧对象名/类型 → 'canvas'。 const inspectLabelFromSource = (value) => { const trimmed = inspectTextValue(value); if (!trimmed) return ''; @@ -488,6 +488,11 @@ const stem = dot > 0 ? segment.slice(0, dot) : (dot === 0 ? '' : segment); return stem.trim(); }; + // 对象名 / 对象类型兜底标签:超过 40 字符一律丢掉,避免把程序化命名塞进引用芯片。 + const inspectObjectLabel = (value) => { + const text = inspectTextValue(value); + return text.length > 40 ? '' : text; + }; const inspectPointerRect = (clientX, clientY) => ({ left: Number.isFinite(clientX) ? clientX : 0, top: Number.isFinite(clientY) ? clientY : 0, @@ -720,6 +725,12 @@ if (!identity.label) { identity.label = inspectTextValue(gameObject && gameObject.texture && gameObject.texture.key); } + if (!identity.label) { + identity.label = inspectObjectLabel(gameObject && gameObject.name); + } + if (!identity.label) { + identity.label = inspectObjectLabel(gameObject && gameObject.type); + } return identity; }; // Phaser 4 的权威换算是 `camera.matrixCombined`(matrix × matrixExternal,Camera.preRender 组装), @@ -914,6 +925,16 @@ identity.label = inspectLabelFromSource(textureUrl); } } + // 没有素材身份时仍给一个画面侧可得的名字:对象名 → 几何类型(BoxGeometry 等)→ 对象类型(Mesh 等)。 + if (!identity.label) { + identity.label = inspectObjectLabel(object && object.name); + } + if (!identity.label) { + identity.label = inspectObjectLabel(object && object.geometry && object.geometry.type); + } + if (!identity.label) { + identity.label = inspectObjectLabel(object && object.type); + } return identity; }; // 命中对象世界包围盒八角投影取 min/max;取不到时不阻塞,退化成指针点矩形。 @@ -963,14 +984,22 @@ } }; const threeInspectTarget = (handle, clientX, clientY) => { - const three = handle.THREE; + // `three` 是推荐形态(只放桥要用的四个构造器);`THREE` 是整命名空间旧形态,保留兼容。 + const three = handle.three || handle.THREE; const scene = handle.scene; const camera = handle.camera; const renderer = handle.renderer; const domElement = renderer && renderer.domElement; if (!three || !scene || !camera || !domElement) return null; if (typeof domElement.getBoundingClientRect !== 'function') return null; - if (typeof three.Vector2 !== 'function' || typeof three.Raycaster !== 'function') return null; + if ( + typeof three.Raycaster !== 'function' + || typeof three.Vector2 !== 'function' + || typeof three.Vector3 !== 'function' + || typeof three.Box3 !== 'function' + ) { + return null; + } const domRect = domElement.getBoundingClientRect(); const width = Number(domRect && domRect.width); const height = Number(domRect && domRect.height); diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/main.js b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/main.js index 2febd40ff..abf55cfd2 100644 --- a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/main.js +++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/main.js @@ -19,9 +19,15 @@ renderer.setSize(window.innerWidth, window.innerHeight); container.append(renderer.domElement); // 发布运行画面实例句柄,供预览桥点选定位场景、相机与渲染器。 +// three 字段只放预览桥要用的构造器:整命名空间会破坏 tree-shaking(实测包体 +41%)。 window.__GENARRATIVE_PREVIEW_GAME__ = { engine: 'three', - THREE, + three: { + Raycaster: THREE.Raycaster, + Vector2: THREE.Vector2, + Vector3: THREE.Vector3, + Box3: THREE.Box3, + }, scene, camera, renderer, diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/main.js b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/main.js index dc0ed61ac..d83345b5e 100644 --- a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/main.js +++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/main.js @@ -19,9 +19,15 @@ renderer.setSize(window.innerWidth, window.innerHeight); container.append(renderer.domElement); // 发布运行画面实例句柄,供预览桥点选定位场景、相机与渲染器。 +// three 字段只放预览桥要用的构造器:整命名空间会破坏 tree-shaking(实测包体 +41%)。 window.__GENARRATIVE_PREVIEW_GAME__ = { engine: 'three', - THREE, + three: { + Raycaster: THREE.Raycaster, + Vector2: THREE.Vector2, + Vector3: THREE.Vector3, + Box3: THREE.Box3, + }, scene, camera, renderer, diff --git a/apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts b/apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts index 087be4370..1457b2ec1 100644 --- a/apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts +++ b/apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts @@ -59,6 +59,8 @@ type FakeSpriteTexture = { type FakeSprite = { visible: boolean; depth: number; + name: string; + type: string; texture: FakeSpriteTexture; getBounds: () => FakeBounds; getData: (key: string) => string | undefined; @@ -125,6 +127,8 @@ function createSprite(options: { textureSource?: { currentSrc?: string; src?: string }; depth?: number; visible?: boolean; + name?: string; + objectType?: string; }): FakeSprite { const texture: FakeSpriteTexture = { key: options.textureKey ?? '' }; const sourceImage = options.sourceImage; @@ -134,6 +138,8 @@ function createSprite(options: { const sprite: FakeSprite = { visible: options.visible ?? true, depth: options.depth ?? 0, + name: options.name ?? '', + type: options.objectType ?? '', texture, getBounds: () => ({ x: options.bounds.left, @@ -304,8 +310,15 @@ function createThreeMesh(options: { userData?: Record; material?: unknown; box?: StubRect; + name?: string; + geometryType?: string; + objectType?: string; }) { const mesh = { + name: options.name ?? '', + type: options.objectType ?? '', + geometry: + options.geometryType === undefined ? {} : { type: options.geometryType }, userData: options.userData ?? {}, material: options.material ?? {}, }; @@ -860,6 +873,47 @@ describe('运行画面点选:Phaser 4 档', () => { expect(degraded?.style.width).toBe('1px'); expect(degraded?.style.height).toBe('1px'); }); + + it('对象没有贴图时标签取对象名 / 对象类型,不再一律落到 canvas', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const named = createSprite({ + bounds: { left: 0, top: 0, width: 100, height: 100 }, + name: 'Hero', + objectType: 'Sprite', + }); + const scene = createPhaserScene({ active: true, interactiveHits: [named] }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'phaser', + game: createPhaserGame({ + canvas, + pointer: { x: 50, y: 50 }, + scenes: [scene], + }), + }; + + sendInspectAction('enable'); + clickAt(canvas, 50, 50); + expect(selectedSelection()).toMatchObject({ + label: 'Hero', + resourceIds: [], + sourcePath: '', + }); + + // 没有对象名时退到对象类型。 + const unnamed = createSprite({ + bounds: { left: 0, top: 0, width: 100, height: 100 }, + objectType: 'Sprite', + }); + scene.input.hitTestPointer = () => [unnamed]; + sendInspectAction('enable'); + posted.length = 0; + clickAt(canvas, 50, 50); + expect(selectedSelection()).toMatchObject({ + label: 'Sprite', + resourceIds: [], + sourcePath: '', + }); + }); }); describe('运行画面点选:three.js 档', () => { @@ -1071,6 +1125,184 @@ describe('运行画面点选:three.js 档', () => { resourceIds: [], }); }); + + it('瘦身句柄(three 只放四个构造器)可命中贴图 Mesh', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const mesh = createThreeMesh({ + material: { + map: { image: { src: 'http://localhost:3000/assets/slim-hero.png' } }, + }, + box: { left: 100, top: 50, width: 200, height: 100 }, + }); + const three = createThreeNamespace({ + hits: [{ object: mesh, distance: 1 }], + projection, + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + three, + scene: { children: [mesh] }, + camera: {}, + renderer: { domElement: canvas }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 200, 200); + + expect(selectedSelection()).toEqual({ + label: 'slim-hero', + elementTag: 'canvas', + elementRole: '', + text: '', + width: 200, + height: 100, + resourceIds: [], + sourcePath: '/assets/slim-hero.png', + }); + }); + + it('旧形态(整个 THREE 命名空间)仍兼容命中', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const mesh = createThreeMesh({ + userData: { genarrativeResourceId: 'local-asset:legacy' }, + box: { left: 0, top: 0, width: 100, height: 100 }, + }); + const three = createThreeNamespace({ + hits: [{ object: mesh, distance: 1 }], + projection, + }); + // 旧句柄把整个 `import * as THREE` 命名空间塞进来:额外成员不影响命中。 + const namespace = { + ...three, + Scene: function Scene() {}, + Color: function Color() {}, + WebGLRenderer: function WebGLRenderer() {}, + }; + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + THREE: namespace, + scene: { children: [mesh] }, + camera: {}, + renderer: { domElement: canvas }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 10, 10); + + expect(selectedSelection()).toMatchObject({ + label: 'canvas', + resourceIds: ['local-asset:legacy'], + sourcePath: '', + }); + }); + + it('命中对象没有素材身份时标签取 object.name', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const mesh = createThreeMesh({ + name: 'Crate', + geometryType: 'BoxGeometry', + objectType: 'Mesh', + box: { left: 0, top: 0, width: 100, height: 100 }, + }); + const three = createThreeNamespace({ + hits: [{ object: mesh, distance: 1 }], + projection, + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + three, + scene: { children: [mesh] }, + camera: {}, + renderer: { domElement: canvas }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 50, 50); + + // 没有任何素材身份(无 userData、材质无贴图):标签不再一律是 canvas。 + expect(selectedSelection()).toMatchObject({ + label: 'Crate', + resourceIds: [], + sourcePath: '', + }); + expect(sanitizedSelection()).toMatchObject({ + label: 'Crate', + resourceIds: [], + }); + expect(sanitizedSelection().sourcePath).toBeUndefined(); + }); + + it('没有对象名时标签取 geometry.type,几何也取不到时取 object.type', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const sphere = createThreeMesh({ + geometryType: 'SphereGeometry', + objectType: 'Mesh', + box: { left: 0, top: 0, width: 100, height: 100 }, + }); + const three = createThreeNamespace({ + hits: [{ object: sphere, distance: 1 }], + projection, + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + three, + scene: { children: [sphere] }, + camera: {}, + renderer: { domElement: canvas }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 50, 50); + expect(selectedSelection()).toMatchObject({ label: 'SphereGeometry' }); + + const group = createThreeMesh({ + objectType: 'Group', + box: { left: 0, top: 0, width: 100, height: 100 }, + }); + const second = createThreeNamespace({ + hits: [{ object: group, distance: 1 }], + projection, + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + three: second, + scene: { children: [group] }, + camera: {}, + renderer: { domElement: canvas }, + }; + sendInspectAction('enable'); + posted.length = 0; + clickAt(canvas, 50, 50); + expect(selectedSelection()).toMatchObject({ label: 'Group' }); + }); + + it('句柄缺 Box3 / Vector3 等构造器时按未命中退化', () => { + const canvas = createCanvas({ left: 0, top: 0, width: 800, height: 600 }); + const mesh = createThreeMesh({ + userData: { genarrativeResourceId: 'local-asset:incomplete' }, + box: { left: 0, top: 0, width: 100, height: 100 }, + }); + const three = createThreeNamespace({ + hits: [{ object: mesh, distance: 1 }], + projection, + }); + bridgeWindow.__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + three: { Raycaster: three.Raycaster, Vector2: three.Vector2 }, + scene: { children: [mesh] }, + camera: {}, + renderer: { domElement: canvas }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 50, 50); + + expect(selectedSelection()).toMatchObject({ + label: 'canvas', + resourceIds: [], + sourcePath: '', + }); + }); }); describe('运行画面点选:退化路径', () => { diff --git a/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md b/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md index 49262159d..84044dd88 100644 --- a/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md @@ -67,8 +67,10 @@ 阶段一让点选能识别素材,但可用性依赖玩法满足三条约定(发布句柄、Phaser 用 Image 装载、对象标注素材身份);阶段二把这几条写进**生成期契约**,新生成的 Phaser 4 / three.js 项目不改代码即可点出素材。同一 PR 续推,`template.zip` 与线上索引仍未重新发布。 -- 主源(唯一写全契约处):`resources/agc-skills/agc-web-game-development/SKILL.md` 新增「运行画面点选契约(运行态素材身份)」四条——发布只读引擎句柄(`{ engine:'phaser', game }` / `{ engine:'three', THREE, scene, camera, renderer }`)、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、退化为不带素材的区域引用(= 验收面)。 +- 主源(唯一写全契约处):`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` 通过。 diff --git a/docs/project-memory/plans/【里程碑】AGC运行画面点选引擎适配-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC运行画面点选引擎适配-2026-10-04.md index fe565a475..d0d9d2e14 100644 --- a/docs/project-memory/plans/【里程碑】AGC运行画面点选引擎适配-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC运行画面点选引擎适配-2026-10-04.md @@ -40,8 +40,8 @@ - [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` 27 例;浏览器重叠位取最上层且显式 id 胜出 `["enemy-tag"]`) -- [x] 无引擎句柄 / 点中空白 / 程序化绘制三种情况:退化为不带素材的区域引用,零报错、零伪造 id,引用可插入可发送。(`runtimeInspectEngines.test.ts` 27 例;浏览器点空白 `@canvas` + `[]` 零报错) +- [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。开箱即用未跑真机链路) @@ -55,7 +55,7 @@ ## 验收证据(2026-10-04) - 命令:`npm run agc:typecheck` 通过(`check:tests:types` / `tsc` / skill-pack 指纹 / 包布局声明 / `check-config` 全过)。 -- 前端用例:`runtimeInspectEngines.test.ts`(27)+ `runtimeInspectResourceMatch.test.ts`(11)+ `localGamePreviewFrame.test.ts`(14)+ `resourceReferenceInput.test.tsx`(43)= 95 passed;`appSurface.test.ts` 197 passed / 9 skipped。 +- 前端用例:`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)、高亮真机目视。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 61f2bcd02..85bada0df 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,12 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-05 把整个 `THREE` 命名空间塞进预览句柄会让 tree-shaking 失效 + +- **现象**:three 项目的运行画面点选句柄写成 `{ engine: 'three', THREE, scene, camera, renderer }` 时,同一个 Vite 构建的产物从 517,682 B 涨到 730,831 B(+213,149 B ≈ +41%)。 +- **原因**:句柄引用整个 `import * as THREE` 命名空间,打包器无法证明未使用的导出可以剪掉;预览桥实际只用 `Raycaster` / `Vector2` / `Vector3` / `Box3` 四个构造器。 +- **结论(现行口径)**:句柄用瘦身形态 `three: { Raycaster, Vector2, Vector3, Box3 }`;该字段只给预览桥用,玩法代码不要引用它。旧形态仍被兼容读取(两种形态都有用例锁定),但新代码与模板一律用瘦身形态。 + ## 2026-10-04 Phaser 4 `hitTestPointer` 的返回顺序不是叠放顺序 - **现象**:运行画面点选 Phaser 4 画面时,按「数组第一个 / 最后一个」当最上层会点错对象——点的是上层精灵,引用却落到下层的素材上;对象越多越容易错。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index e55799474..275e72275 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -75,6 +75,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - **Phaser 4 默认装载下贴图地址不可得**:Phaser 4 默认用 XHR 装载图片,贴图元素上的地址是会话内的临时地址,读不回原始素材路径。这类项目**必须由玩法显式标注素材身份**才能点出素材;没有标注就按第 4 条退化(退化为不带素材的区域引用),不猜。 - **只认玩法发布的只读引擎句柄**:预览页没有发布该句柄、或句柄声明的引擎类型不在支持范围内时,一律退化为不带素材的区域引用;宿主不通过扫描全局对象去猜引擎。 - **生成期契约落在 AGC 玩法 Skill**:句柄发布、Phaser 图片的 Image 装载、素材渲染对象的身份标注、素材在 dist 的稳定路径这四条写进 `agc-web-game-development` 的「运行画面点选契约」,生成期即按契约产出;本能力说明不承诺历史项目自动具备,也不改写既有项目。 +- **three 句柄只放桥要用的构造器**:契约推荐 `{ engine: 'three', three: { Raycaster, Vector2, Vector3, Box3 }, scene, camera, renderer }`;传整个 `THREE` 命名空间会破坏 tree-shaking(同一 Vite 构建实测 517,682 B → 730,831 B,+41%)。旧形态仍被兼容读取,但新代码与模板只用瘦身形态。 ### 验收标准与证据 @@ -82,8 +83,9 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, | ---- | -------- | ---- | | 2、3 | Phaser 4 与 three.js 各一条真机 smoke:起步工程 + 一张已登记素材,点中渲染对象后核对引用芯片名与 `resourceIds` | 真实 Chromium 端到端(真实 Phaser 4.2.1 / three 0.184;Phaser 分别用 `loader.imageLoadType='HTMLImageElement'` 与默认 XHR 两种装载):已登记贴图对象得 `resourceIds=["hero"]`、显式标注对象得 `["enemy-tag"]`、非交互精灵走几何兜底也命中;被测桥脚本 sha256 `6276ac48…917a`。**Tauri 客户端链路未跑**(预览服务器注入与路由由 Rust 定向测试覆盖)。 | | 1、6 | 现有 DOM 点选与运行视窗用例不回归 | `tests/localGamePreviewFrame.test.ts`(14 例)、`tests/resourceReferenceInput.test.tsx`(43 例)、`tests/appSurface.test.ts`(197 passed / 9 skipped)不回归;点选载荷仍走同一套净化与消息版本号。 | -| 4 | 无引擎句柄 / 命中空白 / 程序化绘制三种情况的退化用例 | `tests/runtimeInspectEngines.test.ts`(27 例)覆盖无句柄 / 点空白 / 缺 `renderer.domElement` 三种退化;浏览器点空白得 `@canvas` 且 `resourceIds=[]`、零报错。 | +| 4 | 无引擎句柄 / 命中空白 / 程序化绘制三种情况的退化用例 | `tests/runtimeInspectEngines.test.ts`(33 例)覆盖无句柄 / 点空白 / 缺 `renderer.domElement` 三种退化;浏览器点空白得 `@canvas` 且 `resourceIds=[]`、零报错。 | | 5 | 高亮矩形:声明级用例 + 真机目视 | 浏览器实测高亮矩形与期望逐值相减:Phaser 差 0px、three 最大差 ≈0.0006px;DOM 档仍是元素矩形。 | +| 4、5 | 命中对象但取不到素材身份时:芯片显示画面侧可得的名字(对象名 → 几何类型 → 对象类型),不是一律的 `canvas`;矩形仍是命中对象在画面上的区域 | 真实 Chromium + 真实 three 0.184 + 真实桥脚本实测(瘦身句柄 `three: { Raycaster, Vector2, Vector3, Box3 }`):`object.name = 'Crate'` → 芯片 `Crate`、矩形 143×143;无名字的 `SphereGeometry` → 芯片 `SphereGeometry`、矩形 227×227;旧 `THREE` 命名空间句柄 → 芯片 `LegacyMesh`(两形态都命中);`userData.genarrativeResourceId` → `resourceIds: ["local-asset:probe"]`。两条 label 兜底与两形态句柄另由 `tests/runtimeInspectEngines.test.ts`(33 例)锁定。 | 素材收敛口径由 `tests/runtimeInspectResourceMatch.test.ts`(11 例)覆盖:显式 id、精确后缀、文件名、序列帧四档与去重。 From 629369eb0ca0dc7aea3cb26db7905b16f3d0a73e Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 15:31:44 +0800 Subject: [PATCH 09/14] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E7=94=BB=E9=9D=A2=E7=82=B9=E9=80=89=E7=9A=84=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E7=9C=9F=E6=9C=BA=E8=B8=A9=E5=9D=91=E4=B8=8E=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E4=BA=8C=E7=9C=9F=E6=9C=BA=E5=A4=8D=E9=AA=8C=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pitfalls:新增「three 档句柄少构造器会整体失效」(Raycaster/Vector2/Vector3/Box3 缺任一即不进引擎档、芯片回落 canvas 且不报错,含四步排查顺序)与「预览桥脚本是编译期内嵌的:改完必须重启 AGC 客户端」 - 实施计划·阶段二进展:补真机复验环境已就绪(客户端自本分支编译启动、exe 内 6 处新代码标记已核实、CDP 可用)与「点选复验待执行」(客户端登录态过期停在登录页) --- ...�实施计划】AGC运行画面点选引擎适配-2026-10-04.md | 1 + docs/project-memory/shared-memory/pitfalls.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md b/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md index 84044dd88..a335ba215 100644 --- a/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC运行画面点选引擎适配-2026-10-04.md @@ -74,3 +74,4 @@ - 冻结面不变:`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 内句柄,不写"已完成"。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 85bada0df..ac48aeef9 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -8,6 +8,20 @@ - **原因**:句柄引用整个 `import * as THREE` 命名空间,打包器无法证明未使用的导出可以剪掉;预览桥实际只用 `Raycaster` / `Vector2` / `Vector3` / `Box3` 四个构造器。 - **结论(现行口径)**:句柄用瘦身形态 `three: { Raycaster, Vector2, Vector3, Box3 }`;该字段只给预览桥用,玩法代码不要引用它。旧形态仍被兼容读取(两种形态都有用例锁定),但新代码与模板一律用瘦身形态。 +## 2026-10-05 运行画面点选 three 档的句柄少了构造器会整体失效 + +- **现象**:three 项目句柄里 `Raycaster` / `Vector2` / `Vector3` / `Box3` 只缺一个,桥就**不进引擎档**:芯片回落 `@canvas`、控制台不报错——症状与「根本没发句柄」完全一样,容易误判成「点选没生效」。 +- **原因**:引擎档入口先认句柄形态、再逐个校验这四个构造器是否为 function,缺任一即按未命中退化(不降级、不猜);射线需要 `Raycaster` + `Vector2`,对象矩形需要 `Box3` + `Vector3`,两组能力缺一方都点不出对象。 +- **排查顺序(现行口径)**:① 预览页有没有 `window.__GENARRATIVE_PREVIEW_GAME__`;② `engine` 是不是 `phaser` / `three`;③ 这四个构造器齐不齐(瘦身形态是 `three.Raycaster` 等,旧形态是 `THREE.Raycaster`);④ `scene` / `camera` / `renderer`,three 侧还要 `renderer.domElement` 带 `getBoundingClientRect`。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js`(`threeInspectTarget`)、`agc-web-game-development` 的「运行画面点选契约」、`tests/runtimeInspectEngines.test.ts`(瘦身句柄命中、缺构造器退化两例)。 + +## 2026-10-05 预览桥脚本是编译期内嵌的:改完必须重启 AGC 客户端 + +- **现象**:改了桥脚本(运行画面点选 / 尺寸上报逻辑),Vite HMR 与刷新预览页都不换——预览页仍跑旧桥,新加的判定与兜底完全不生效。 +- **原因**:`apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js` 由 `preview.rs` 用 `include_str!` **编译期**编进客户端 exe,预览服务器 `/__genarrative/local-preview-fit.js` 返回的就是 exe 里那份常量,重读磁盘不会发生。 +- **结论(现行口径)**:改桥后必须重启 AGC 客户端才生效——先确认没有在跑的 AGC Vite(3080 等端口空闲)再 `npm run agc`;只刷新页面、只重启后端或只重装 npm 依赖都无效。核实内嵌版本:在 exe 二进制里搜新代码标记,或比对 `resources/preview/local-preview-fit.js` 的 sha256。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/preview.rs`(`PREVIEW_FIT_BRIDGE_SCRIPT`)、`apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js`、`genarrative-dev-stack-port-routing`(端口探测与 `npm run agc` 口径)。 + ## 2026-10-04 Phaser 4 `hitTestPointer` 的返回顺序不是叠放顺序 - **现象**:运行画面点选 Phaser 4 画面时,按「数组第一个 / 最后一个」当最上层会点错对象——点的是上层精灵,引用却落到下层的素材上;对象越多越容易错。 From 8d1980488e52746006394fc61c8178e021b361b8 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 15:43:41 +0800 Subject: [PATCH 10/14] =?UTF-8?q?=E8=A1=A5=E9=A2=84=E8=A7=88=E6=A1=A5?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E4=B8=8E=E5=8E=BB=E9=87=8D=E7=9A=84=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E7=A9=BA=E7=99=BD=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 踩坑记录新增 2026-10-05 条:preview.rs 的标签注入与去重没有用例覆盖,列出 4 个既有用例并给出人工验证清单 --- docs/project-memory/shared-memory/pitfalls.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index ac48aeef9..c92b4ceee 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -22,6 +22,13 @@ - **结论(现行口径)**:改桥后必须重启 AGC 客户端才生效——先确认没有在跑的 AGC Vite(3080 等端口空闲)再 `npm run agc`;只刷新页面、只重启后端或只重装 npm 依赖都无效。核实内嵌版本:在 exe 二进制里搜新代码标记,或比对 `resources/preview/local-preview-fit.js` 的 sha256。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/preview.rs`(`PREVIEW_FIT_BRIDGE_SCRIPT`)、`apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js`、`genarrative-dev-stack-port-routing`(端口探测与 `npm run agc` 口径)。 +## 2026-10-05 预览桥的注入与去重在 Rust 侧没有用例覆盖 + +- **现象 / 风险**:改动 `preview.rs` 的注入逻辑(`inject_preview_fit_bridge` / `build_preview_response` / `PREVIEW_FIT_BRIDGE_TAG`)时,没有自动化门禁会告诉你「标签没注入」或「注入了两次」。两种失效都只在运行时才暴露:没注入等于整套运行画面点选静默失效(桥脚本的尺寸上报与点选都不执行,页面看起来完全正常);注入两次会让桥的监听、尺寸上报与点选回调各注册一遍,页面同样看不出差别。 +- **现状**:`preview.rs` 的 `mod tests` 只有 4 个用例——`npm_preview_requires_build_and_prefers_bundled_assets`、`root_layout_serves_root_entry_and_keeps_legacy_paths_available`、`root_layout_does_not_expose_control_or_data_directories`、`legacy_layout_serves_root_ui_modules`;它们只断言预览路由的选取、状态行与页面自身文本,不涉及桥标签是否出现、出现几次,也不覆盖 `scan_preview_fit_bridge_html` 的「页面已带标签就不重复注入」分支(`inject_preview_fit_bridge` 里的 `if scan.has_bridge_script { return html.into_bytes(); }`)。 +- **结论(现行口径)**:这类「把常量原样返回 / 标签字符串存在」的转发型行为不固化成长用例(仓库口径:复制与源码文本断言不进测试)。改注入逻辑时按人工验证清单核对:① 响应 HTML 里 ```` 出现在 `` 前,且是经典脚本(不带 `type` / `nomodule`);② 页面自身已带该标签时,响应里的标签数量不增加;③ `/__genarrative/local-preview-fit.js` 返回 200,且内容与 `resources/preview/local-preview-fit.js` 逐字节一致(`include_str!` 内嵌,可比 sha256)。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/preview.rs`(`PREVIEW_FIT_BRIDGE_SCRIPT` / `PREVIEW_FIT_BRIDGE_TAG` / `inject_preview_fit_bridge`)、`apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js`。 + ## 2026-10-04 Phaser 4 `hitTestPointer` 的返回顺序不是叠放顺序 - **现象**:运行画面点选 Phaser 4 画面时,按「数组第一个 / 最后一个」当最上层会点错对象——点的是上层精灵,引用却落到下层的素材上;对象越多越容易错。 From 7ad412f0ec473f23c653d2ff36910d6d692d324f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 17:08:03 +0800 Subject: [PATCH 11/14] =?UTF-8?q?=E4=BF=AE=E8=BF=90=E8=A1=8C=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E7=82=B9=E9=80=89=E5=90=8E=E9=A2=84=E8=A7=88=E8=87=AA?= =?UTF-8?q?=E5=B7=B1=E7=BC=A9=E6=94=BE=EF=BC=9A=E9=AB=98=E4=BA=AE=E6=A1=86?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=8F=82=E4=B8=8E=E5=B0=BA=E5=AF=B8=E4=B8=8A?= =?UTF-8?q?=E6=8A=A5=EF=BC=8C=E5=91=BD=E4=B8=AD=E7=9F=A9=E5=BD=A2=E8=A3=81?= =?UTF-8?q?=E5=89=AA=E5=88=B0=E7=94=BB=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:高亮框(body 下 data-genarrative-preview-inspect,position:fixed)被尺寸上报的 measureContentBounds 遍历计入内容尺寸;three 投影盒超出画布时把内容尺寸撑到 3756×2643(视口 900×560),宿主改 iframe 尺寸触发游戏 resize 重算相机,形成自激振荡 - 桥:measureContentBounds 的 TreeWalker 对 data-genarrative-preview-fit / data-genarrative-preview-inspect 返回 FILTER_REJECT(桥自己的节点不参与内容测量) - 桥:新增 clipInspectRect,引擎档(three 投影盒、Phaser 世界包围盒)命中矩形统一裁剪到画布可见范围;与画布无交集时退化为指针点矩形(不出现 0/负尺寸) - 测试:新增 localPreviewInspectSizeStability.test.ts(5 例:桥节点不参与测量、同尺寸普通节点仍计入的对照组、进入检查模式与 hover 后尺寸与宿主适配布局不变、投影盒超出画布时载荷被裁剪、与画布无交集时退化为 1×1);runtimeInspectEngines 的字段换算用例改到画布内的等价参数(原矩形落在画布外,裁剪后按新规则退化为指针点矩形) - 文档:pitfalls 记一条「高亮框被尺寸上报当成页面内容」的根因、口径与回归 --- .../resources/preview/local-preview-fit.js | 36 +- .../localPreviewInspectSizeStability.test.ts | 523 ++++++++++++++++++ .../tests/runtimeInspectEngines.test.ts | 9 +- docs/project-memory/shared-memory/pitfalls.md | 8 + 4 files changed, 569 insertions(+), 7 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/localPreviewInspectSizeStability.test.ts diff --git a/apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js b/apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js index 5ff19cd48..d5a34392e 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js +++ b/apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js @@ -170,9 +170,24 @@ heightBaseline: height.baseline, }; }; + // 桥自己的节点(尺寸上报的 style、点选高亮框)不属于页面内容:高亮框还是 `position: fixed` + // 且跟随命中对象,一旦被 `measureContentBounds` 当成固有尺寸,宿主就会据此改 iframe 尺寸、 + // 游戏 resize 重算相机——实测表现为「点选后画面自己缩放」。 + const isPreviewBridgeNode = (element) => { + const dataset = element && element.dataset; + if (!dataset) return false; + return ( + dataset.genarrativePreviewFit !== undefined + || dataset.genarrativePreviewInspect !== undefined + ); + }; const measureContentBounds = (body, viewportWidth, viewportHeight) => { if (!body) return { width: 0, height: 0, truncated: false }; - const walker = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT); + const walker = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT, { + acceptNode: (node) => ( + isPreviewBridgeNode(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT + ), + }); let width = 0; let height = 0; let widthBaseline = 0; @@ -499,6 +514,19 @@ width: 1, height: 1, }); + // 命中矩形裁剪到画布可见范围:引擎侧的世界 / 投影包围盒可能远超画布,高亮框与载荷都不该出现 + // 比画面还大的矩形(同时它也是「点选后画面自己缩放」的输入之一,见 measureContentBounds 注释)。 + // 与画布无交集时返回 null,由调用方退化成指针点矩形。 + const clipInspectRect = (rect, canvasRect) => { + if (!rect || !canvasRect) return null; + const left = Math.max(rect.left, canvasRect.left); + const top = Math.max(rect.top, canvasRect.top); + const right = Math.min(rect.left + rect.width, canvasRect.left + canvasRect.width); + const bottom = Math.min(rect.top + rect.height, canvasRect.top + canvasRect.height); + if (![left, top, right, bottom].every(Number.isFinite)) return null; + if (right <= left || bottom <= top) return null; + return { left, top, width: Math.max(1, right - left), height: Math.max(1, bottom - top) }; + }; const engineInspectSelection = (identity, rect) => ({ label: identity.label || 'canvas', elementTag: 'canvas', @@ -835,7 +863,8 @@ } catch { boundsRect = null; } - const rect = boundsRect || inspectPointerRect(clientX, clientY); + const canvasRect = canvasElement.getBoundingClientRect(); + const rect = clipInspectRect(boundsRect, canvasRect) || inspectPointerRect(clientX, clientY); return { selection: engineInspectSelection(phaserResourceIdentity(gameObject), rect), rect }; }; const phaserInspectTarget = (handle, canvasElement, clientX, clientY) => { @@ -1016,7 +1045,8 @@ const hit = Array.isArray(hits) && hits.length > 0 ? hits[0] : null; const object = hit && hit.object; if (!object) return null; - const rect = threeBoxScreenRect(three, object, camera, domRect) || inspectPointerRect(clientX, clientY); + const rect = clipInspectRect(threeBoxScreenRect(three, object, camera, domRect), domRect) + || inspectPointerRect(clientX, clientY); return { selection: engineInspectSelection(threeResourceIdentity(object), rect), rect }; }; const resolveInspectEngineSelection = (canvasElement, clientX, clientY) => { diff --git a/apps/ai-game-creator-shell/tests/localPreviewInspectSizeStability.test.ts b/apps/ai-game-creator-shell/tests/localPreviewInspectSizeStability.test.ts new file mode 100644 index 000000000..1848d578b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/localPreviewInspectSizeStability.test.ts @@ -0,0 +1,523 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resolveLocalGamePreviewFitLayout } from '../src/features/project-workspace/LocalGamePreviewFrame'; +import { repoPath } from './repoPath'; + +/** + * 「点选不改变预览尺寸」的回归用例。 + * + * 用户实测 BUG:three 项目里点选 3D 对象后预览画面自己缩放/视角异常。根因是桥自己的高亮框 + * (`data-genarrative-preview-inspect`,`position: fixed`,尺寸随命中对象)被尺寸上报的 + * `measureContentBounds` 当成页面固有内容 → 宿主据此改 iframe 尺寸 → 游戏 `resize` 重算相机。 + * 这里在 jsdom 里加载**真实桥脚本**、用假 `window.parent` 收尺寸消息,断言: + * 高亮框(以及任何桥自己的节点)出现、移动、消失都不改变尺寸上报与宿主算出的适配布局。 + */ +const BRIDGE_SOURCE = readFileSync( + repoPath( + 'apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js', + ), + 'utf8', +); +const SIZE_MESSAGE = 'genarrative.local-preview-size.v1'; +const INSPECT_MESSAGE = 'genarrative.local-preview-inspect.v1'; +const VIEWPORT = { width: 900, height: 560 }; + +type SizePayload = { + type?: string; + contentWidth?: number; + contentHeight?: number; + viewportWidth?: number; + viewportHeight?: number; +}; + +type Posted = SizePayload & { + action?: string; + selection?: Record; +}; + +type StubRect = { left: number; top: number; width: number; height: number }; + +const posted: Posted[] = []; +const rectOverrides = new WeakMap(); + +function fakeRect(rect: StubRect) { + return { + ...rect, + x: rect.left, + y: rect.top, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + toJSON: () => ({}), + }; +} + +function stubRect(element: Element, rect: StubRect): void { + rectOverrides.set(element, rect); +} + +function sizeMessages(): SizePayload[] { + return posted.filter((message) => message.type === SIZE_MESSAGE); +} + +function lastSize(): SizePayload { + const messages = sizeMessages(); + const message = messages[messages.length - 1]; + if (!message) throw new Error('未收到尺寸上报'); + return message; +} + +function lastSelection(): Record { + const message = [...posted] + .reverse() + .find( + (entry) => entry.type === INSPECT_MESSAGE && entry.action === 'selected', + ); + if (!message?.selection) throw new Error('未收到点选载荷'); + return message.selection; +} + +/** 触发一次尺寸上报:桥按 sizeKey 去重,先制造一次不同的内容尺寸再回到当前尺寸。 */ +function publishSize(): SizePayload { + const scratch = document.createElement('div'); + stubRect(scratch, { left: 0, top: 0, width: 12345, height: 12345 }); + document.body.appendChild(scratch); + window.dispatchEvent(new Event('resize')); + scratch.remove(); + window.dispatchEvent(new Event('resize')); + const messages = sizeMessages(); + if (messages.length === 0) throw new Error('未收到尺寸上报'); + return messages[messages.length - 1] as SizePayload; +} + +function overlayElement(): HTMLElement | null { + return document.querySelector('[data-genarrative-preview-inspect="true"]'); +} + +function overlayStyleRect() { + const overlay = overlayElement(); + if (!overlay) return null; + return [ + overlay.style.left, + overlay.style.top, + overlay.style.width, + overlay.style.height, + ]; +} + +function sendInspectAction(action: 'enable' | 'disable'): void { + window.dispatchEvent( + new MessageEvent('message', { + source: window.parent as unknown as MessageEventSource, + data: { type: INSPECT_MESSAGE, action }, + }), + ); +} + +function createCanvas(): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + stubRect(canvas, { left: 0, top: 0, ...VIEWPORT }); + document.body.appendChild(canvas); + return canvas; +} + +function clickAt(target: Element, clientX: number, clientY: number): void { + target.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + cancelable: true, + clientX, + clientY, + }), + ); +} + +function moveAt(target: Element, clientX: number, clientY: number): void { + target.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX, clientY }), + ); +} + +/** 假 three:够桥走完射线 + 八角投影。 */ +function createThreeFake(options: { + hits: { object: unknown; distance: number }[]; + /** 世界坐标 → 屏幕坐标(与页面同尺度),用来造出「投影盒超出画布」。 */ + projection: (x: number, y: number) => { x: number; y: number }; +}) { + const meshBox = new WeakMap< + object, + { min: { x: number; y: number }; max: { x: number; y: number } } + >(); + class Vector2 { + constructor( + public x: number, + public y: number, + ) {} + } + class Vector3 { + constructor( + public x: number, + public y: number, + public z: number, + ) {} + project(): this { + const point = options.projection(this.x, this.y); + this.x = point.x; + this.y = point.y; + return this; + } + } + class Box3 { + min = { x: 0, y: 0, z: 0 }; + max = { x: 0, y: 0, z: 0 }; + setFromObject(object: unknown): this { + if (object && typeof object === 'object') { + const box = meshBox.get(object); + if (box) { + this.min = { ...box.min, z: 0 }; + this.max = { ...box.max, z: 0 }; + } + } + return this; + } + } + class Raycaster { + setFromCamera(): void {} + intersectObjects(): { object: unknown; distance: number }[] { + return options.hits; + } + } + return { + namespace: { Vector2, Vector3, Box3, Raycaster }, + createMesh( + object: object, + box: { min: { x: number; y: number }; max: { x: number; y: number } }, + ) { + meshBox.set(object, box); + return object; + }, + }; +} + +function stubDocumentMetrics(): void { + Object.defineProperty(window, 'innerWidth', { + value: VIEWPORT.width, + configurable: true, + }); + Object.defineProperty(window, 'innerHeight', { + value: VIEWPORT.height, + configurable: true, + }); + // 真实 AGC 游戏页面不留 body 外边距;否则桥把 margin 计入固有尺寸(900 → 916)。 + document.body.style.margin = '0'; + document.documentElement.style.margin = '0'; + for (const element of [document.documentElement, document.body]) { + Object.defineProperty(element, 'offsetWidth', { + value: VIEWPORT.width, + configurable: true, + }); + Object.defineProperty(element, 'offsetHeight', { + value: VIEWPORT.height, + configurable: true, + }); + Object.defineProperty(element, 'scrollWidth', { + value: VIEWPORT.width, + configurable: true, + }); + Object.defineProperty(element, 'scrollHeight', { + value: VIEWPORT.height, + configurable: true, + }); + stubRect(element, { left: 0, top: 0, ...VIEWPORT }); + } +} + +beforeEach(() => { + posted.length = 0; + document.body.innerHTML = ''; + stubDocumentMetrics(); + // 桥只在 window.parent !== window 时上报尺寸:这里放一个假的父窗口记录 postMessage。 + Object.defineProperty(window, 'parent', { + value: { postMessage: (message: Posted) => posted.push(message) }, + configurable: true, + }); + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((( + callback: FrameRequestCallback, + ) => { + callback(0); + return 0; + }) as unknown as typeof window.requestAnimationFrame); + vi.spyOn(window, 'setInterval').mockImplementation( + (() => 0) as unknown as typeof window.setInterval, + ); + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation( + function (this: Element): DOMRect { + const override = rectOverrides.get(this); + if (override) return fakeRect(override) as unknown as DOMRect; + if ( + this instanceof HTMLElement && + this.dataset?.genarrativePreviewInspect !== undefined + ) { + // 真实浏览器里高亮框的 border-box = inline style 的 left/top/size + 2px 边框。 + const px = (value: string) => Number.parseFloat(value) || 0; + return fakeRect({ + left: px(this.style.left), + top: px(this.style.top), + width: px(this.style.width) + 4, + height: px(this.style.height) + 4, + }) as unknown as DOMRect; + } + return fakeRect({ + left: 0, + top: 0, + width: 0, + height: 0, + }) as unknown as DOMRect; + }, + ); + if ( + document.querySelector('[data-genarrative-preview-fit="true"]') === null + ) { + // eslint-disable-next-line no-new-func + new Function(BRIDGE_SOURCE)(); + } +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('运行画面点选不改变预览尺寸', () => { + it('尺寸上报把桥自己的节点排除在内容测量之外(同样大小的普通节点仍会被计入)', () => { + const baseline = publishSize(); + expect(baseline.contentWidth).toBe(VIEWPORT.width); + expect(baseline.contentHeight).toBe(VIEWPORT.height); + + // 对照组:普通节点撑开内容 → 上报必须变化(证明这条用例能抓住「内容尺寸被撑大」)。 + const plain = document.createElement('div'); + stubRect(plain, { left: 0, top: 0, width: 4000, height: 3000 }); + document.body.appendChild(plain); + const widened = publishSize(); + expect(widened.contentWidth).toBe(4000); + expect(widened.contentHeight).toBe(3000); + plain.remove(); + + // 实验组:同样的尺寸,但带桥自己的标记 → 不得参与测量。 + const bridgeOwned = document.createElement('div'); + bridgeOwned.dataset.genarrativePreviewInspect = 'true'; + stubRect(bridgeOwned, { left: 0, top: 0, width: 4000, height: 3000 }); + document.body.appendChild(bridgeOwned); + const afterBridgeNode = publishSize(); + expect(afterBridgeNode.contentWidth).toBe(VIEWPORT.width); + expect(afterBridgeNode.contentHeight).toBe(VIEWPORT.height); + bridgeOwned.remove(); + }); + + it('进入检查模式与 hover 出现高亮框后:尺寸上报与宿主适配布局都不变', () => { + const canvas = createCanvas(); + const mesh = { + name: 'Tower', + type: 'Mesh', + geometry: { type: 'CylinderGeometry' }, + userData: {}, + material: {}, + }; + const three = createThreeFake({ + hits: [{ object: mesh, distance: 1 }], + projection: (x, y) => ({ + x: (x / VIEWPORT.width) * 2 - 1, + y: 1 - (y / VIEWPORT.height) * 2, + }), + }); + // 投影盒远超画布:真实项目里贴近相机的大物体就是这样。 + three.createMesh(mesh, { + min: { x: -400, y: -300 }, + max: { x: 1300, y: 860 }, + }); + ( + window as unknown as { __GENARRATIVE_PREVIEW_GAME__?: unknown } + ).__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + three: three.namespace, + scene: { children: [mesh] }, + camera: {}, + renderer: { domElement: canvas }, + }; + + const baseline = publishSize(); + expect(sizeMessages().length).toBeGreaterThan(0); + expect( + resolveLocalGamePreviewFitLayout(VIEWPORT, { + contentWidth: baseline.contentWidth as number, + contentHeight: baseline.contentHeight as number, + viewportWidth: baseline.viewportWidth as number, + viewportHeight: baseline.viewportHeight as number, + }), + ).toEqual({ width: VIEWPORT.width, height: VIEWPORT.height, scale: 1 }); + + sendInspectAction('enable'); + moveAt(canvas, 450, 280); + // 高亮框被裁剪到画布内:不再是超出画面的投影盒。 + expect(overlayStyleRect()).toEqual(['0px', '0px', '900px', '560px']); + + const afterHover = publishSize(); + expect(afterHover.contentWidth).toBe(baseline.contentWidth); + expect(afterHover.contentHeight).toBe(baseline.contentHeight); + expect( + resolveLocalGamePreviewFitLayout(VIEWPORT, { + contentWidth: afterHover.contentWidth as number, + contentHeight: afterHover.contentHeight as number, + viewportWidth: afterHover.viewportWidth as number, + viewportHeight: afterHover.viewportHeight as number, + }), + ).toEqual({ width: VIEWPORT.width, height: VIEWPORT.height, scale: 1 }); + + clickAt(canvas, 450, 280); + const afterClick = publishSize(); + expect(afterClick.contentWidth).toBe(baseline.contentWidth); + expect(afterClick.contentHeight).toBe(baseline.contentHeight); + expect(overlayElement()).toBeNull(); + }); + + it('three 投影盒超出画布时:载荷矩形被裁剪到画布内,width/height 不为 0', () => { + const canvas = createCanvas(); + const mesh = { + name: 'Tower', + type: 'Mesh', + geometry: { type: 'CylinderGeometry' }, + userData: {}, + material: {}, + }; + const three = createThreeFake({ + hits: [{ object: mesh, distance: 1 }], + projection: (x, y) => ({ + x: (x / VIEWPORT.width) * 2 - 1, + y: 1 - (y / VIEWPORT.height) * 2, + }), + }); + three.createMesh(mesh, { + min: { x: -400, y: -300 }, + max: { x: 1300, y: 860 }, + }); + ( + window as unknown as { __GENARRATIVE_PREVIEW_GAME__?: unknown } + ).__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + three: three.namespace, + scene: { children: [mesh] }, + camera: {}, + renderer: { domElement: canvas }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 450, 280); + + expect(lastSelection()).toMatchObject({ + label: 'Tower', + elementTag: 'canvas', + width: VIEWPORT.width, + height: VIEWPORT.height, + resourceIds: [], + sourcePath: '', + }); + }); + + it('投影盒与画布完全无交集时退化为指针点矩形,不产生 0 或负尺寸', () => { + const canvas = createCanvas(); + const mesh = { + name: 'Far', + type: 'Mesh', + geometry: { type: 'BoxGeometry' }, + userData: {}, + material: {}, + }; + const three = createThreeFake({ + hits: [{ object: mesh, distance: 1 }], + projection: (x, y) => ({ + x: (x / VIEWPORT.width) * 2 - 1, + y: 1 - (y / VIEWPORT.height) * 2, + }), + }); + three.createMesh(mesh, { + min: { x: 2000, y: 1500 }, + max: { x: 2600, y: 1900 }, + }); + ( + window as unknown as { __GENARRATIVE_PREVIEW_GAME__?: unknown } + ).__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + three: three.namespace, + scene: { children: [mesh] }, + camera: {}, + renderer: { domElement: canvas }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 120, 90); + + expect(lastSelection()).toMatchObject({ width: 1, height: 1 }); + }); + + it('Phaser 世界包围盒超出画布时同样裁剪到画布内', () => { + const canvas = createCanvas(); + const sprite = { + name: 'Big', + type: 'Sprite', + visible: true, + depth: 0, + texture: { key: 'big' }, + getBounds: () => ({ x: -500, y: -400, width: 2000, height: 1600 }), + getData: () => undefined, + }; + const scene = { + sys: { isActive: () => true }, + cameras: { + main: { + scrollX: 0, + scrollY: 0, + zoom: 1, + x: 0, + y: 0, + width: VIEWPORT.width, + height: VIEWPORT.height, + originX: 0.5, + originY: 0.5, + rotation: 0, + getWorldPoint: (x: number, y: number) => ({ x, y }), + }, + }, + input: { + hitTestPointer: (pointer: { camera?: unknown }) => { + pointer.camera = scene.cameras.main; + return [sprite]; + }, + }, + children: { list: [sprite] }, + }; + ( + window as unknown as { __GENARRATIVE_PREVIEW_GAME__?: unknown } + ).__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'phaser', + game: { + input: { activePointer: { x: 450, y: 280 } }, + scene: { scenes: [scene] }, + scale: { + gameSize: { width: VIEWPORT.width, height: VIEWPORT.height }, + transformX: (pageX: number) => pageX, + transformY: (pageY: number) => pageY, + }, + }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 450, 280); + + expect(lastSelection()).toMatchObject({ + label: 'big', + width: VIEWPORT.width, + height: VIEWPORT.height, + }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts b/apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts index 1457b2ec1..5da32aade 100644 --- a/apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts +++ b/apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts @@ -710,8 +710,8 @@ describe('运行画面点选:Phaser 4 档', () => { data: { genarrativeResourceId: 'local-asset:plain' }, }); const camera = createPhaserCamera({ - scrollX: 10, - scrollY: 20, + scrollX: -400, + scrollY: -300, zoom: 2, x: 3, y: 4, @@ -733,8 +733,9 @@ describe('运行画面点选:Phaser 4 档', () => { sendInspectAction('enable'); clickAt(canvas, 50, 40); - // 世界 (10,20)-(110,70) 经 origin=0.5 / zoom=2 / scroll=(10,20) / camera=(3,4) - // → 屏幕 (-397,-296)-( -197,-196):宽 200、高 100。 + // 相机字段换算(origin=0.5 / zoom=2 / scroll=(-400,-300) / camera=(3,4)): + // 世界 (10,20)-(110,70) → 屏幕 (423,344)-(623,444),宽 200、高 100(世界尺寸的 2 倍)。 + // 矩形落在画布内,画布裁剪不改变它。 expect(selectedSelection()).toMatchObject({ width: 200, height: 100, diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index c92b4ceee..b9f45cae2 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,14 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-05 运行画面点选的高亮框被尺寸上报当成页面内容:点选后预览自己缩放 + +- **现象**:three 项目里点选 3D 对象后预览画面自己缩放、视角异常(相机 aspect 与重新适配的距离都变了),点选结束后视口仍停在放大后的尺寸。实测夹具里是**自激振荡**:10 秒内 1459 条尺寸消息、1457 次 iframe 尺寸/缩放变更、1454 次 `resize`,相机 aspect 在 1.607143 ↔ 1.421112 之间来回变。 +- **原因**:高亮框是 `document.body` 下的 `div[data-genarrative-preview-inspect]`(`position: fixed`、尺寸随命中对象),而尺寸上报的 `measureContentBounds` 用 `createTreeWalker(body, SHOW_ELEMENT)` 遍历所有元素并把 `rect.right/bottom` 计入内容尺寸。three 档的高亮矩形来自 `Box3` 八角投影,物体贴近相机时投影盒远超画布(实测 6607×4721、left/top 为负)→ 上报内容被撑到 3756×2643(视口只有 900×560)→ 宿主 `resolveLocalGamePreviewFitLayout` 把 iframe 改成 3756×2643 + scale 0.2119 → 游戏 `resize` 重算相机 → 视口变大又让投影盒更大,如此循环。 +- **结论(现行口径)**:① 桥自己的节点一律不进内容尺寸测量——`measureContentBounds` 的 TreeWalker 用 `acceptNode` 对 `data-genarrative-preview-fit` / `data-genarrative-preview-inspect` 返回 `FILTER_REJECT`;以后新增任何桥注入的 DOM 节点都要带上这两个标记之一,否则会重新引入这条反馈。② 引擎档命中矩形统一裁剪到画布可见范围(`clipInspectRect`;与画布无交集时退化为指针点矩形),不再出现比画面还大的高亮框。 +- **回归**:`apps/ai-game-creator-shell/tests/localPreviewInspectSizeStability.test.ts`(5 例:桥节点不参与测量、同尺寸普通节点仍计入的对照组、进入检查模式与 hover 后尺寸与宿主适配布局不变、投影盒超出画布时载荷被裁剪、与画布无交集时退化为 1×1)。 +- **关联**:`resources/preview/local-preview-fit.js`(`measureContentBounds` / `clipInspectRect` / `threeInspectTarget` / `phaserSelection`)、`features/project-workspace/LocalGamePreviewFrame.tsx`(`resolveLocalGamePreviewFitLayout`)。 + ## 2026-10-05 把整个 `THREE` 命名空间塞进预览句柄会让 tree-shaking 失效 - **现象**:three 项目的运行画面点选句柄写成 `{ engine: 'three', THREE, scene, camera, renderer }` 时,同一个 Vite 构建的产物从 517,682 B 涨到 730,831 B(+213,149 B ≈ +41%)。 From 582fabdfbba8dabfa1f82778218887a64885b102 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 17:11:45 +0800 Subject: [PATCH 12/14] =?UTF-8?q?=E8=A1=A5=E7=82=B9=E9=80=89=E4=B8=8D?= =?UTF-8?q?=E6=94=B9=E5=8F=98=E9=A2=84=E8=A7=88=E9=80=82=E9=85=8D=E7=9A=84?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E5=90=88=E5=90=8C=E4=B8=8E=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 功能说明「必须成立的行为」新增第 7、8 条:点选不改变预览适配、命中区域裁剪在画布内 - 功能说明「验收标准与证据」补两行:尺寸稳定性用例(5 例)与真实 Chromium 对照值、命中区域裁剪与退化证据 - PRD §3.4「点选素材」条款末尾补一句:点选不得改变预览的内容尺寸与适配缩放,命中区域只取画布内可见部分 --- docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md | 2 +- docs/【功能说明】AGC聊天素材引用-2026-09-08.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index ee9e7de9c..ee996e017 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -76,7 +76,7 @@ - 运行视窗必须占满中央工作区为游戏保留的可用区域。loopback 预览页通过客户端本地 preview server 注入的只读尺寸桥上报文档实际宽高;宿主只接受当前 iframe、当前 loopback origin 的固定版本消息,并将完整游戏文档等比缩放、居中放入视窗。iframe 首次适配后发生的真实内容增高或缩短仍必须被接受;仅浏览上下文宽高回灌或内容宽高未变化时保持当前状态,不触发重复渲染。 - 窗口或中央区域尺寸变化后必须重新测量和适配;内容已经放得下时保持 `1:1`,不得无故放大。游戏文档宽高超过视窗时缩小整体画面,不显示 iframe 横向或纵向滚动条,也不得用单纯裁切替代完整展示。尺寸桥以根布局 `ResizeObserver` 为主,并在页面可见时每 `500ms` 至多探测 `512` 个元素作为绝对定位溢出的低频兜底;探测截断时不得用部分样本下调尺寸,viewport 耦合的 `100vh / 100% / bottom / right` 布局也不得形成自反馈。相同测量结果去重,不监听整页属性、文本或子节点突变;桥不读取项目正文、不修改 manifest、游戏文件或运行业务状态。桥脚本只能注入到真实 HTML 标签上下文,不能把脚本、样式、模板或注释中的 `` / `` 文本误判为结束标签;省略结束标签的 UTF-8 HTML 仍需安全注入。 - 运行视窗右下角提供“全屏预览”:只把游戏画面那一格送进全屏,顶部页签、右侧对话和底部信息栏不跟着放大;再次点击该入口、按 `Esc` 或由宿主退出全屏都回到原布局。宿主没有 Fullscreen API 时整枚入口不渲染,不留点了没反应的按钮。 -- 运行视窗提供“点选素材”:把画面上点中的内容落成一条 `runtime-region` 引用插入对话,只影响这一次引用——不改资源选中、不改信息栏判据、不写 manifest 或版本、不重载预览,也不代表“该素材正在被这个位置使用”。预览页是 Phaser 4 或 three.js 玩法时,点中由**已登记素材**渲染的精灵、图集帧、贴图网格或模型,引用必须带该素材的稳定 id,显示名与其它引用入口一致;玩法没有发布引擎句柄、对象既没有标注也取不到贴图地址、或画面是程序化绘制时,退化为不带素材的区域引用——不报错、不伪造 id、引用照常能插入能发送。 +- 运行视窗提供“点选素材”:把画面上点中的内容落成一条 `runtime-region` 引用插入对话,只影响这一次引用——不改资源选中、不改信息栏判据、不写 manifest 或版本、不重载预览,也不代表“该素材正在被这个位置使用”。预览页是 Phaser 4 或 three.js 玩法时,点中由**已登记素材**渲染的精灵、图集帧、贴图网格或模型,引用必须带该素材的稳定 id,显示名与其它引用入口一致;玩法没有发布引擎句柄、对象既没有标注也取不到贴图地址、或画面是程序化绘制时,退化为不带素材的区域引用——不报错、不伪造 id、引用照常能插入能发送;点选不得改变预览的内容尺寸与适配缩放,命中区域只取画布内可见部分。 - 运行视窗下方的信息栏只在**有真实内容**时存在(当前判据是**资源选中态**:在资源画布或浮层资源面板里选中一张资源后切到运行页签仍保留,信息栏渲染它的只读字段;运行画面上的“点选素材”只往对话插入引用,不改选中):没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;不显示示例字段、默认数值、未载入控件或功能说明。暂时没有数据源的区域(「数值微调」的登记表)不渲染区域标题与卡片,等编辑态登记表接进来后与内容一起出现。Agent 对话标题栏不显示头像图标,“与陶泥儿的对话”及副标题按标题栏左侧对齐,钱包和审批入口继续位于右侧。 - 数值修改立即写入当前项目的编辑态配置。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 275e72275..9508ebb68 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -65,6 +65,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, 4. **无法确定素材时退化**:画面为程序化绘制、对象没有标注也取不到贴图地址、预览页没有发布引擎句柄——全部退化为**不带素材**的区域引用,与今天的 DOM 退化语义一致:不报错、不静默丢弃,引用仍能插入、能发送;Rust 对未知素材 id 的既有拒绝口径不变。 5. **命中反馈**:能定位到命中对象时,高亮范围是命中对象在画面上的区域;取不到对象矩形时退化为与 DOM 档一致的高亮范围。 6. **协议与净化不变**:运行画面点选与 DOM 点选共用同一套预览消息协议、同一套字段净化与数量上限;引擎侧取到的标识必须先过同一套净化才能进入引用载荷。 +7. **点选不改变预览适配**:进入检查模式、悬停、点中对象都不得改变预览页上报的内容尺寸与宿主算出的适配布局;点选自身的高亮层不属于页面内容,不得参与内容尺寸测量。 +8. **命中区域裁剪在画布内**:上报/绘制的命中区域只取画布范围内的可见部分;命中区域与画布无交集时退化为指针点区域,不产生超出画布的巨型区域。 ### 边界与非目标 @@ -86,6 +88,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, | 4 | 无引擎句柄 / 命中空白 / 程序化绘制三种情况的退化用例 | `tests/runtimeInspectEngines.test.ts`(33 例)覆盖无句柄 / 点空白 / 缺 `renderer.domElement` 三种退化;浏览器点空白得 `@canvas` 且 `resourceIds=[]`、零报错。 | | 5 | 高亮矩形:声明级用例 + 真机目视 | 浏览器实测高亮矩形与期望逐值相减:Phaser 差 0px、three 最大差 ≈0.0006px;DOM 档仍是元素矩形。 | | 4、5 | 命中对象但取不到素材身份时:芯片显示画面侧可得的名字(对象名 → 几何类型 → 对象类型),不是一律的 `canvas`;矩形仍是命中对象在画面上的区域 | 真实 Chromium + 真实 three 0.184 + 真实桥脚本实测(瘦身句柄 `three: { Raycaster, Vector2, Vector3, Box3 }`):`object.name = 'Crate'` → 芯片 `Crate`、矩形 143×143;无名字的 `SphereGeometry` → 芯片 `SphereGeometry`、矩形 227×227;旧 `THREE` 命名空间句柄 → 芯片 `LegacyMesh`(两形态都命中);`userData.genarrativeResourceId` → `resourceIds: ["local-asset:probe"]`。两条 label 兜底与两形态句柄另由 `tests/runtimeInspectEngines.test.ts`(33 例)锁定。 | +| 7 | `tests/localPreviewInspectSizeStability.test.ts`(5 例,对修复前的桥 5 failed) | 真实 Chromium 夹具对照:尺寸消息 1459 → 3、iframe 布局变更 1457 → 1、游戏 `resize` 1454 → 1、相机 `aspect` 由振荡变为恒定 1.607143。 | +| 8 | 同文件用例覆盖「投影盒超出画布被裁剪」与「无交集退化为 1×1 指针点区域」 | 真实夹具高亮矩形由 `[-2852,-2078,6607,4721]` 变为 `[0,0,904,564]`(画布 900×560 + 2px 边框)。 | 素材收敛口径由 `tests/runtimeInspectResourceMatch.test.ts`(11 例)覆盖:显式 id、精确后缀、文件名、序列帧四档与去重。 From 985b8f9fa66862d37f33471cc5a86c8bdac5b497 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 17:12:59 +0800 Subject: [PATCH 13/14] =?UTF-8?q?=E5=8E=BB=E6=8E=89=E9=95=BF=E6=9C=9F?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E9=87=8C=E8=BF=87=E6=9C=9F=E7=9A=84=E6=A1=A5?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E5=93=88=E5=B8=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 功能说明「验收标准与证据」条款 2、3 行:删掉「被测桥脚本 sha256 `6276ac48…917a`」这句(实现产物哈希会随桥改动过期,取证时点与版本由 PR 评论承载),其余行为证据与 Tauri 链路结论原样保留 --- docs/【功能说明】AGC聊天素材引用-2026-09-08.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 9508ebb68..c9667049d 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -83,7 +83,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, | 条款 | 验收方式 | 证据 | | ---- | -------- | ---- | -| 2、3 | Phaser 4 与 three.js 各一条真机 smoke:起步工程 + 一张已登记素材,点中渲染对象后核对引用芯片名与 `resourceIds` | 真实 Chromium 端到端(真实 Phaser 4.2.1 / three 0.184;Phaser 分别用 `loader.imageLoadType='HTMLImageElement'` 与默认 XHR 两种装载):已登记贴图对象得 `resourceIds=["hero"]`、显式标注对象得 `["enemy-tag"]`、非交互精灵走几何兜底也命中;被测桥脚本 sha256 `6276ac48…917a`。**Tauri 客户端链路未跑**(预览服务器注入与路由由 Rust 定向测试覆盖)。 | +| 2、3 | Phaser 4 与 three.js 各一条真机 smoke:起步工程 + 一张已登记素材,点中渲染对象后核对引用芯片名与 `resourceIds` | 真实 Chromium 端到端(真实 Phaser 4.2.1 / three 0.184;Phaser 分别用 `loader.imageLoadType='HTMLImageElement'` 与默认 XHR 两种装载):已登记贴图对象得 `resourceIds=["hero"]`、显式标注对象得 `["enemy-tag"]`、非交互精灵走几何兜底也命中。**Tauri 客户端链路未跑**(预览服务器注入与路由由 Rust 定向测试覆盖)。 | | 1、6 | 现有 DOM 点选与运行视窗用例不回归 | `tests/localGamePreviewFrame.test.ts`(14 例)、`tests/resourceReferenceInput.test.tsx`(43 例)、`tests/appSurface.test.ts`(197 passed / 9 skipped)不回归;点选载荷仍走同一套净化与消息版本号。 | | 4 | 无引擎句柄 / 命中空白 / 程序化绘制三种情况的退化用例 | `tests/runtimeInspectEngines.test.ts`(33 例)覆盖无句柄 / 点空白 / 缺 `renderer.domElement` 三种退化;浏览器点空白得 `@canvas` 且 `resourceIds=[]`、零报错。 | | 5 | 高亮矩形:声明级用例 + 真机目视 | 浏览器实测高亮矩形与期望逐值相减:Phaser 差 0px、three 最大差 ≈0.0006px;DOM 档仍是元素矩形。 | From df221f4eb98c9aec0caa280bd1b0e9f478b42190 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 18:31:59 +0800 Subject: [PATCH 14/14] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E8=AF=84=E5=AE=A1=20P2?= =?UTF-8?q?=EF=BC=9A=E7=82=B9=E9=80=89=E6=9D=A1=E6=AC=BE=E9=99=90=E5=AE=9A?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E6=A1=A3=E3=80=81=E5=AF=B9=E8=B1=A1=E5=90=8D?= =?UTF-8?q?=E4=B8=8A=E9=99=90=E5=85=A5=E8=BE=B9=E7=95=8C=E3=80=81=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=20id=20=E6=A0=BC=E5=BC=8F=E5=86=99=E5=85=A5=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 功能说明第 8 条「命中区域裁剪在画布内」限定为引擎档,并说明 DOM 档仍是元素矩形;证据表对应行同步对齐 - 功能说明「边界与非目标」新增一条:引擎档对象名超过 40 字符按取不到处理(有意保留),宿主侧标签上限另为 120 - SKILL.md「运行画面点选契约」第 3 条补一句:素材 id 不含空白与路径分隔符(localAssetId 由客户端生成) - skill-pack 指纹同步(agc-web-game-development version 2026-08-26.42 与 sha256) --- .../resources/agc-skills/agc-web-game-development/SKILL.md | 2 +- .../src-tauri/resources/agc-skills/manifest.json | 4 ++-- docs/【功能说明】AGC聊天素材引用-2026-09-08.md | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md index 5df2767a7..af3d6ddff 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md @@ -38,7 +38,7 @@ When implementing a new game loop or a broad gameplay revision, read `references - 这四个构造器是预览桥做射线与包围盒投影的最小集;该字段只给预览桥用,玩法代码不要引用它。 - 旧形态 `{ engine: 'three', THREE, scene, camera, renderer }` 仍被兼容读取,新代码一律用上面的瘦身形态。 2. **Phaser 图片用 Image 标签装载**:游戏配置固定 `loader: { imageLoadType: 'HTMLImageElement' }`。默认的 XHR 装载把贴图地址换成会话内临时地址,点选读不回原始素材路径;这类项目只能靠第 3 条的显式标注。 -3. **给渲染对象标注素材身份**:用已登记素材渲染的对象要带上 manifest 的稳定身份,值只取 `agc_list_registered_assets` 返回的 `localAssetId` / `localPath`,不自行编造、不改写文件名。 +3. **给渲染对象标注素材身份**:用已登记素材渲染的对象要带上 manifest 的稳定身份,值只取 `agc_list_registered_assets` 返回的 `localAssetId` / `localPath`,不自行编造、不改写文件名;素材 id 不含空白与路径分隔符(`localAssetId` 由客户端生成)。 - Phaser:`sprite.setData('genarrativeResourceId', '')`,或 `sprite.setData('genarrativeResourcePath', '')`;两者都给时 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 路径对不上,点选只剩文件名兜底,重名素材直接丢掉。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 504209264..142e16b6d 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.41", + "version": "2026-08-26.42", "skills": [ { "name": "agc-unity-editor", @@ -121,7 +121,7 @@ "agents/openai.yaml", "references/game-quality-checklist.md" ], - "sha256": "c0227101a9940dbf1882ba274ff775cbc20653780465663223d8f9b2d0167b85" + "sha256": "9959892b2b92a62aa5e8ca12a1938483a1bafc57c8b8e4520fed6547ed38d0de" }, { "name": "agc-browser-playtest", diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index c9667049d..822abc90a 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -66,7 +66,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, 5. **命中反馈**:能定位到命中对象时,高亮范围是命中对象在画面上的区域;取不到对象矩形时退化为与 DOM 档一致的高亮范围。 6. **协议与净化不变**:运行画面点选与 DOM 点选共用同一套预览消息协议、同一套字段净化与数量上限;引擎侧取到的标识必须先过同一套净化才能进入引用载荷。 7. **点选不改变预览适配**:进入检查模式、悬停、点中对象都不得改变预览页上报的内容尺寸与宿主算出的适配布局;点选自身的高亮层不属于页面内容,不得参与内容尺寸测量。 -8. **命中区域裁剪在画布内**:上报/绘制的命中区域只取画布范围内的可见部分;命中区域与画布无交集时退化为指针点区域,不产生超出画布的巨型区域。 +8. **命中区域裁剪在画布内**:引擎档的命中区域只取画布范围内的可见部分;命中区域与画布无交集时退化为指针点区域,不产生超出画布的巨型区域。DOM 档仍是元素矩形。 ### 边界与非目标 @@ -76,6 +76,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 不做像素级或颜色拾取,不为了识别素材去改写玩法源码、资源文件或渲染结果。 - **Phaser 4 默认装载下贴图地址不可得**:Phaser 4 默认用 XHR 装载图片,贴图元素上的地址是会话内的临时地址,读不回原始素材路径。这类项目**必须由玩法显式标注素材身份**才能点出素材;没有标注就按第 4 条退化(退化为不带素材的区域引用),不猜。 - **只认玩法发布的只读引擎句柄**:预览页没有发布该句柄、或句柄声明的引擎类型不在支持范围内时,一律退化为不带素材的区域引用;宿主不通过扫描全局对象去猜引擎。 +- **引擎档对象名有 40 字符上限(有意保留)**:引擎档命中对象的名字超过 40 字符时按取不到处理,标签退到几何类型 / 对象类型;宿主侧标签上限另为 120。取 40 是为了避免程序化生成的长名塞进引用芯片。 - **生成期契约落在 AGC 玩法 Skill**:句柄发布、Phaser 图片的 Image 装载、素材渲染对象的身份标注、素材在 dist 的稳定路径这四条写进 `agc-web-game-development` 的「运行画面点选契约」,生成期即按契约产出;本能力说明不承诺历史项目自动具备,也不改写既有项目。 - **three 句柄只放桥要用的构造器**:契约推荐 `{ engine: 'three', three: { Raycaster, Vector2, Vector3, Box3 }, scene, camera, renderer }`;传整个 `THREE` 命名空间会破坏 tree-shaking(同一 Vite 构建实测 517,682 B → 730,831 B,+41%)。旧形态仍被兼容读取,但新代码与模板只用瘦身形态。 @@ -89,7 +90,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, | 5 | 高亮矩形:声明级用例 + 真机目视 | 浏览器实测高亮矩形与期望逐值相减:Phaser 差 0px、three 最大差 ≈0.0006px;DOM 档仍是元素矩形。 | | 4、5 | 命中对象但取不到素材身份时:芯片显示画面侧可得的名字(对象名 → 几何类型 → 对象类型),不是一律的 `canvas`;矩形仍是命中对象在画面上的区域 | 真实 Chromium + 真实 three 0.184 + 真实桥脚本实测(瘦身句柄 `three: { Raycaster, Vector2, Vector3, Box3 }`):`object.name = 'Crate'` → 芯片 `Crate`、矩形 143×143;无名字的 `SphereGeometry` → 芯片 `SphereGeometry`、矩形 227×227;旧 `THREE` 命名空间句柄 → 芯片 `LegacyMesh`(两形态都命中);`userData.genarrativeResourceId` → `resourceIds: ["local-asset:probe"]`。两条 label 兜底与两形态句柄另由 `tests/runtimeInspectEngines.test.ts`(33 例)锁定。 | | 7 | `tests/localPreviewInspectSizeStability.test.ts`(5 例,对修复前的桥 5 failed) | 真实 Chromium 夹具对照:尺寸消息 1459 → 3、iframe 布局变更 1457 → 1、游戏 `resize` 1454 → 1、相机 `aspect` 由振荡变为恒定 1.607143。 | -| 8 | 同文件用例覆盖「投影盒超出画布被裁剪」与「无交集退化为 1×1 指针点区域」 | 真实夹具高亮矩形由 `[-2852,-2078,6607,4721]` 变为 `[0,0,904,564]`(画布 900×560 + 2px 边框)。 | +| 8 | 同文件用例覆盖引擎档的「投影盒超出画布被裁剪」与「无交集退化为 1×1 指针点区域」 | 真实夹具高亮矩形由 `[-2852,-2078,6607,4721]` 变为 `[0,0,904,564]`(画布 900×560 + 2px 边框);DOM 档仍给元素矩形。 | 素材收敛口径由 `tests/runtimeInspectResourceMatch.test.ts`(11 例)覆盖:显式 id、精确后缀、文件名、序列帧四档与去重。