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