diff --git a/GDJS/Runtime/InGameEditor/InGameEditor.tsx b/GDJS/Runtime/InGameEditor/InGameEditor.tsx index 42c1c974d7..9db1d49fce 100644 --- a/GDJS/Runtime/InGameEditor/InGameEditor.tsx +++ b/GDJS/Runtime/InGameEditor/InGameEditor.tsx @@ -582,6 +582,13 @@ namespace gdjs { return gdjs.Base3DHandler.is3D(object); }; + type ScreenArea = { + minX: float; + minY: float; + maxX: float; + maxY: float; + }; + type AABB3D = { min: Point3D; max: Point3D; @@ -1051,6 +1058,16 @@ namespace gdjs { private _editedLayerDataList: LayerData[] = []; private _selectedLayerName: string = ''; private _innerArea: AABB3D | null = null; + /** + * The part of the game frame that is not covered by the editor panels, + * in ratios of the game frame size. + */ + private _visibleScreenArea: ScreenArea = { + minX: 0, + minY: 0, + maxX: 1, + maxY: 1, + }; private _threeInnerArea: THREE.Object3D | null = null; private _unregisterContextLostListener: (() => void) | null = null; private _tempVector2d: THREE.Vector2 = new THREE.Vector2(); @@ -1756,6 +1773,7 @@ namespace gdjs { maxX: number; maxY: number; }) { + this.setVisibleScreenArea(visibleScreenArea); if (this._innerArea) { this.zoomToFitArea( { @@ -1791,6 +1809,7 @@ namespace gdjs { maxX: number; maxY: number; }) { + this.setVisibleScreenArea(visibleScreenArea); const editedInstanceContainer = this.getEditedInstanceContainer(); if (!editedInstanceContainer) return; @@ -1807,6 +1826,7 @@ namespace gdjs { maxX: number; maxY: number; }) { + this.setVisibleScreenArea(visibleScreenArea); this.zoomToFitObjects( this._selection.getSelectedObjects(), visibleScreenArea, @@ -1983,13 +2003,20 @@ namespace gdjs { this._getEditorCamera().switchToOrbitAroundObject(object); } + setVisibleScreenArea(visibleScreenArea: ScreenArea) { + this._visibleScreenArea = visibleScreenArea; + } + private _focusOnSelection() { - // TODO Use the center of the AABB of the whole selection instead - const selectedObject = this._selection.getLastSelectedObject(); - if (!selectedObject) { + const selectedObjects = this._selection.getSelectedObjects(); + if (selectedObjects.length === 0) { return; } - this._getEditorCamera().switchToOrbitAroundObject(selectedObject); + this._getEditorCamera().frameObjectsInScreenArea( + selectedObjects, + this._visibleScreenArea, + 0.1 + ); } private _handleCameraMovement() { @@ -4917,6 +4944,168 @@ namespace gdjs { this.onHasCameraChanged(); } + /** + * Move the camera, keeping its orientation, so that the AABB of the objects + * fits in the given area of the screen (e.g. the space between the editor panels). + * + * @param objects The objects to frame. + * @param screenArea The area where to frame the objects, in ratios of the screen size. + * @param margin The ratio of the screen area size kept empty on each side. + */ + frameObjectsInScreenArea( + objects: Array, + screenArea: ScreenArea, + margin: float + ): void { + if (objects.length === 0) return; + let minX = Number.MAX_VALUE; + let minY = Number.MAX_VALUE; + let minZ = Number.MAX_VALUE; + let maxX = -Number.MAX_VALUE; + let maxY = -Number.MAX_VALUE; + let maxZ = -Number.MAX_VALUE; + for (const object of objects) { + const aabb = object.getAABB(); + minX = Math.min(minX, aabb.min[0]); + minY = Math.min(minY, aabb.min[1]); + minZ = Math.min(minZ, is3D(object) ? object.getUnrotatedAABBMinZ() : 0); + maxX = Math.max(maxX, aabb.max[0]); + maxY = Math.max(maxY, aabb.max[1]); + maxZ = Math.max(maxZ, is3D(object) ? object.getUnrotatedAABBMaxZ() : 0); + } + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const centerZ = (minZ + maxZ) / 2; + + // Keep the orientation of the camera that is currently used. + const activeCamera = this.getActiveCamera(); + const yaw = gdjs.toRad(activeCamera.rotationAngle + 90); + const elevation = gdjs.toRad(activeCamera.elevationAngle); + const forward: Point3D = [ + -Math.cos(yaw) * Math.cos(elevation), + -Math.sin(yaw) * Math.cos(elevation), + -Math.sin(elevation), + ]; + const right: Point3D = [Math.sin(yaw), -Math.cos(yaw), 0]; + // up = forward x right + const up: Point3D = [ + forward[1] * right[2] - forward[2] * right[1], + forward[2] * right[0] - forward[0] * right[2], + forward[0] * right[1] - forward[1] * right[0], + ]; + + // Express the AABB corners in the camera basis, relatively to the AABB center. + const cornersRight: Array = []; + const cornersUp: Array = []; + const cornersDepth: Array = []; + for (const cornerX of [minX, maxX]) { + for (const cornerY of [minY, maxY]) { + for (const cornerZ of [minZ, maxZ]) { + const deltaX = cornerX - centerX; + const deltaY = cornerY - centerY; + const deltaZ = cornerZ - centerZ; + cornersRight.push( + deltaX * right[0] + deltaY * right[1] + deltaZ * right[2] + ); + cornersUp.push(deltaX * up[0] + deltaY * up[1] + deltaZ * up[2]); + cornersDepth.push( + deltaX * forward[0] + deltaY * forward[1] + deltaZ * forward[2] + ); + } + } + } + + // The screen area in normalized device coordinates (y going up), with the margin. + const areaWidth = screenArea.maxX - screenArea.minX; + const areaHeight = screenArea.maxY - screenArea.minY; + if (areaWidth <= 0 || areaHeight <= 0) return; + const ndcLeft = 2 * (screenArea.minX + margin * areaWidth) - 1; + const ndcRight = 2 * (screenArea.maxX - margin * areaWidth) - 1; + const ndcTop = 1 - 2 * (screenArea.minY + margin * areaHeight); + const ndcBottom = 1 - 2 * (screenArea.maxY - margin * areaHeight); + + const runtimeGame = this.editor.getRuntimeGame(); + const aspectRatio = + runtimeGame.getGameResolutionWidth() / + runtimeGame.getGameResolutionHeight(); + const tanHalfVerticalFov = Math.tan(0.5 * gdjs.toRad(editorCameraFov)); + const tanHalfHorizontalFov = tanHalfVerticalFov * aspectRatio; + + // For a camera distance, the range of camera offsets (along one screen axis) + // that keeps every corner inside [ndcMin, ndcMax], or null if there is none. + const getOffsetRange = ( + distance: float, + cornerOffsets: Array, + tanHalfFov: float, + ndcMin: float, + ndcMax: float + ): [float, float] | null => { + let offsetMin = -Number.MAX_VALUE; + let offsetMax = Number.MAX_VALUE; + for (let index = 0; index < cornerOffsets.length; index++) { + const halfExtent = (distance + cornersDepth[index]) * tanHalfFov; + offsetMin = Math.max( + offsetMin, + cornerOffsets[index] - ndcMax * halfExtent + ); + offsetMax = Math.min( + offsetMax, + cornerOffsets[index] - ndcMin * halfExtent + ); + } + return offsetMin <= offsetMax ? [offsetMin, offsetMax] : null; + }; + const getOffsetRanges = (distance: float) => { + const rightRange = getOffsetRange( + distance, + cornersRight, + tanHalfHorizontalFov, + ndcLeft, + ndcRight + ); + const upRange = getOffsetRange( + distance, + cornersUp, + tanHalfVerticalFov, + ndcBottom, + ndcTop + ); + return rightRange && upRange ? { rightRange, upRange } : null; + }; + + // Find the smallest distance for which the corners fit (the fitting is monotonic). + const minimumDistance = 10; + let nearDistance = Math.max( + minimumDistance, + -Math.min(...cornersDepth) + minimumDistance + ); + let farDistance = nearDistance; + while (!getOffsetRanges(farDistance) && farDistance < 1e9) { + nearDistance = farDistance; + farDistance *= 2; + } + for (let iteration = 0; iteration < 40; iteration++) { + const middleDistance = (nearDistance + farDistance) / 2; + if (getOffsetRanges(middleDistance)) { + farDistance = middleDistance; + } else { + nearDistance = middleDistance; + } + } + const offsetRanges = getOffsetRanges(farDistance); + if (!offsetRanges) return; + const rightOffset = + (offsetRanges.rightRange[0] + offsetRanges.rightRange[1]) / 2; + const upOffset = (offsetRanges.upRange[0] + offsetRanges.upRange[1]) / 2; + + this.switchToOrbitAroundPosition( + centerX + rightOffset * right[0] + upOffset * up[0], + centerY + rightOffset * right[1] + upOffset * up[1], + centerZ + rightOffset * right[2] + upOffset * up[2] + ); + this.setOrbitDistance(farDistance); + } + step(): void { const runtimeGame = this.editor.getRuntimeGame(); const inputManager = runtimeGame.getInputManager(); diff --git a/GDJS/Runtime/debugger-client/abstract-debugger-client.ts b/GDJS/Runtime/debugger-client/abstract-debugger-client.ts index 0984638d19..4ab10cafb7 100644 --- a/GDJS/Runtime/debugger-client/abstract-debugger-client.ts +++ b/GDJS/Runtime/debugger-client/abstract-debugger-client.ts @@ -499,6 +499,10 @@ namespace gdjs { if (inGameEditor) { inGameEditor.zoomToFitContent(data.payload.visibleScreenArea); } + } else if (data.command === 'setVisibleScreenArea') { + if (inGameEditor) { + inGameEditor.setVisibleScreenArea(data.payload.visibleScreenArea); + } } else if (data.command === 'setSelectedLayer') { if (inGameEditor) { inGameEditor.setSelectedLayerName(data.payload.layerName); diff --git a/newIDE/app/src/EmbeddedGame/EmbeddedGameFrame.js b/newIDE/app/src/EmbeddedGame/EmbeddedGameFrame.js index df9fc20d04..327b4dfc93 100644 --- a/newIDE/app/src/EmbeddedGame/EmbeddedGameFrame.js +++ b/newIDE/app/src/EmbeddedGame/EmbeddedGameFrame.js @@ -11,6 +11,7 @@ import { registerOpenedDialogsCountCallback } from '../UI/Dialog'; import { getActiveEmbeddedGameFrameHoleRect, registerActiveEmbeddedGameFrameHoleCountCallback, + registerEmbeddedGameFrameHoleResizeCallback, } from './EmbeddedGameFrameHole'; import KeyboardShortcuts from '../UI/KeyboardShortcuts'; import { useInGameEditorSettings } from './InGameEditorSettings'; @@ -263,6 +264,57 @@ export const EmbeddedGameFrame = ({ iframe.contentWindow.focus(); }, []); + // Send the part of the game frame that is not covered by the editor panels, + // either with a command changing the view or so that the in-game editor + // can use it later (e.g. to focus on the selection). + const sendVisibleScreenArea = React.useCallback( + (command: ChangeViewPositionCommand | 'setVisibleScreenArea') => { + const iframe = iframeRef.current; + if (!iframe || !previewDebuggerServer) return; + + const embeddedGameFrameRect = iframe.getBoundingClientRect(); + const embeddedGameFrameHoleRect = getActiveEmbeddedGameFrameHoleRect(); + if ( + !embeddedGameFrameHoleRect || + !embeddedGameFrameRect.width || + !embeddedGameFrameRect.height + ) + return; + + const visibleScreenArea = { + minX: + (embeddedGameFrameHoleRect.left - embeddedGameFrameRect.left) / + embeddedGameFrameRect.width, + minY: + (embeddedGameFrameHoleRect.top - embeddedGameFrameRect.top) / + embeddedGameFrameRect.height, + maxX: + (embeddedGameFrameHoleRect.right - embeddedGameFrameRect.left) / + embeddedGameFrameRect.width, + maxY: + (embeddedGameFrameHoleRect.bottom - embeddedGameFrameRect.top) / + embeddedGameFrameRect.height, + }; + previewDebuggerServer + .getExistingEmbeddedGameFrameDebuggerIds() + .forEach(debuggerId => { + previewDebuggerServer.sendMessage(debuggerId, { + command, + payload: { visibleScreenArea }, + }); + }); + }, + [previewDebuggerServer] + ); + + React.useEffect( + () => + registerEmbeddedGameFrameHoleResizeCallback(() => + sendVisibleScreenArea('setVisibleScreenArea') + ), + [sendVisibleScreenArea] + ); + const inGameEditorSettings = useInGameEditorSettings(); React.useEffect( () => { @@ -434,6 +486,7 @@ export const EmbeddedGameFrame = ({ editorCamera3D: cameraStates.current.get(editorId), }); }); + sendVisibleScreenArea('setVisibleScreenArea'); } }; onSwitchInGameEditorIfNoHotReloadIsNeeded = ({ @@ -476,43 +529,10 @@ export const EmbeddedGameFrame = ({ cameraState3D: cameraStates.current.get(editorId), }); }); + sendVisibleScreenArea('setVisibleScreenArea'); }; onChangeViewPosition = (command: ChangeViewPositionCommand) => { - const iframe = iframeRef.current; - if (!iframe) return; - - const embeddedGameFrameRect = iframe.getBoundingClientRect(); - const embeddedGameFrameHoleRect = getActiveEmbeddedGameFrameHoleRect(); - if (!embeddedGameFrameHoleRect || !embeddedGameFrameRect) return; - - if (!previewDebuggerServer) return; - previewDebuggerServer - .getExistingEmbeddedGameFrameDebuggerIds() - .forEach(debuggerId => { - previewDebuggerServer.sendMessage(debuggerId, { - command, - payload: { - visibleScreenArea: { - minX: - (embeddedGameFrameHoleRect.left - - embeddedGameFrameRect.left) / - embeddedGameFrameRect.width, - minY: - (embeddedGameFrameHoleRect.top - - embeddedGameFrameRect.top) / - embeddedGameFrameRect.height, - maxX: - (embeddedGameFrameHoleRect.right - - embeddedGameFrameRect.left) / - embeddedGameFrameRect.width, - maxY: - (embeddedGameFrameHoleRect.bottom - - embeddedGameFrameRect.top) / - embeddedGameFrameRect.height, - }, - }, - }); - }); + sendVisibleScreenArea(command); }; }, [ @@ -520,6 +540,7 @@ export const EmbeddedGameFrame = ({ previewIndexHtmlLocation, onLaunchPreviewForInGameEdition, enabled, + sendVisibleScreenArea, ] ); diff --git a/newIDE/app/src/EmbeddedGame/EmbeddedGameFrameHole.js b/newIDE/app/src/EmbeddedGame/EmbeddedGameFrameHole.js index be923d53cb..c0442f7803 100644 --- a/newIDE/app/src/EmbeddedGame/EmbeddedGameFrameHole.js +++ b/newIDE/app/src/EmbeddedGame/EmbeddedGameFrameHole.js @@ -31,6 +31,23 @@ const notifyActiveEmbeddedGameFrameHoleCountCallbacks = () => { ); }; +let embeddedGameFrameHoleResizeCallbacks: Array<() => void> = []; +export const registerEmbeddedGameFrameHoleResizeCallback = ( + callback: () => void +): (() => void) => { + embeddedGameFrameHoleResizeCallbacks.push(callback); + return () => { + embeddedGameFrameHoleResizeCallbacks.splice( + embeddedGameFrameHoleResizeCallbacks.indexOf(callback), + 1 + ); + }; +}; + +const notifyEmbeddedGameFrameHoleResizeCallbacks = () => { + embeddedGameFrameHoleResizeCallbacks.forEach(callback => callback()); +}; + export const getActiveEmbeddedGameFrameHoleRect = (): ?ClientRect => { // There is only one embedded game frame hole active at a time, // so we don't need to check if the parent scene editor is active. @@ -67,8 +84,27 @@ export const EmbeddedGameFrameHole = (props: Props): React.MixedElement => { [props.isActive] ); + // Notify when the hole is resized (panels opened, closed or resized), so that + // the in-game editor knows the part of the game frame that is visible. + const holeRef = React.useRef(null); + React.useEffect( + () => { + const hole = holeRef.current; + if (!props.isActive || !hole || typeof ResizeObserver === 'undefined') + return; + + const resizeObserver = new ResizeObserver( + notifyEmbeddedGameFrameHoleResizeCallbacks + ); + resizeObserver.observe(hole); + return () => resizeObserver.disconnect(); + }, + [props.isActive] + ); + return (