From 6c6850352efd8a4bdc29869705693fc37342ad48 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 20:45:48 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E8=BF=90=E8=A1=8C=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E7=82=B9=E9=80=89=E6=96=B0=E5=A2=9E=20Phaser=204=20=E4=B8=8E?= =?UTF-8?q?=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', + ]); + }); +});