namespace gdjs { const logger = new gdjs.Logger('In-Game editor'); /** * A minimal utility to define DOM elements. * Also copied in InGameDebugger.tsx. */ function h( tag: K, attrs: { style?: Partial; onClick?: () => void; }, ...nodes: (HTMLElement | string)[] ): HTMLElement { const node = document.createElement(tag); Object.keys(attrs).forEach((key) => { if (key === 'style') { for (const [styleName, value] of Object.entries(attrs.style!)) { node.style[styleName] = value; } } else if (key === 'onClick') { node.addEventListener('click', attrs[key]!); } else { node.setAttribute(key, '' + attrs[key]); } }); node.append(...nodes); return node; } /** * Adapt the Three.js TransformControls gizmos so that the axis are in the same direction as in GDevelop. * This does not change the way the controls work (notably when dragged), it's only a visual adaptation. */ const patchAxesOnTransformControlsGizmos = ( controls: THREE_ADDONS.TransformControls ) => { const gizmoRoot = controls && (controls as any)._gizmo; if (!gizmoRoot) return; // Flip gizmo (visual), picker (raycast), and helper (guide lines) const groupsToFlip = ['gizmo', 'picker', 'helper']; // Bake axis reflections into geometry so per-frame handle scaling // inside TransformControlsGizmo.updateMatrixWorld() won't undo it. const flipY = new THREE.Matrix4().makeScale(1, -1, 1); const flipX = new THREE.Matrix4().makeScale(-1, 1, 1); // For translate mode: flip Y-axis handles const shouldFlipYByName = (name) => name === 'Y' || name === 'XY' || name === 'YZ'; // For scale mode: flip X-axis handles const shouldFlipXByName = (name) => name === 'X' || name === 'XY' || name === 'XZ'; // Process translate mode (flip Y) for (const group of groupsToFlip) { const root = gizmoRoot[group] && gizmoRoot[group]['translate']; if (!root) continue; root.traverse((obj) => { if (!obj || !obj.geometry) return; const name = obj.name || ''; if (!shouldFlipYByName(name)) return; // Bake the Y flip directly into the geometry obj.geometry.applyMatrix4(flipY); // Keep raycasting bounds correct after mutation if (typeof obj.geometry.computeBoundingBox === 'function') { obj.geometry.computeBoundingBox(); } if (typeof obj.geometry.computeBoundingSphere === 'function') { obj.geometry.computeBoundingSphere(); } }); } // Process scale mode (flip X) for (const group of groupsToFlip) { const root = gizmoRoot[group] && gizmoRoot[group]['scale']; if (!root) continue; root.traverse((obj) => { if (!obj || !obj.geometry) return; const name = obj.name || ''; if (!shouldFlipXByName(name)) return; // Bake the X flip directly into the geometry obj.geometry.applyMatrix4(flipX); // Keep raycasting bounds correct after mutation if (typeof obj.geometry.computeBoundingBox === 'function') { obj.geometry.computeBoundingBox(); } if (typeof obj.geometry.computeBoundingSphere === 'function') { obj.geometry.computeBoundingSphere(); } }); } }; const patchColorsOnTransformControlsGizmos = ( controls: THREE_ADDONS.TransformControls ) => { const gizmoRoot = controls && (controls as any)._gizmo; if (!gizmoRoot) return; const colorMap = { x: 0xf53e63, y: 0xa4e507, z: 0x36a9f5, e: 0xffff00, xyz: 0xffffff, xyze: 0x787878, highlight: 0xeeeeee, }; const modes = ['translate', 'rotate', 'scale']; const groups = ['gizmo', 'helper']; // Helper to determine which color to use based on handle name const getColorForHandle = (name) => { const nameLower = (name || '').toLowerCase(); if (nameLower === 'xyze') return colorMap.xyze; if (nameLower === 'xyz') return colorMap.xyz; if (nameLower === 'e') return colorMap.e; // For plane handles (XY, YZ, XZ), use the color of the missing axis if (nameLower === 'xy') return colorMap.z; if (nameLower === 'yz') return colorMap.x; if (nameLower === 'xz') return colorMap.y; // Single axis handles if (nameLower === 'x' || nameLower.includes('x')) return colorMap.x; if (nameLower === 'y' || nameLower.includes('y')) return colorMap.y; if (nameLower === 'z' || nameLower.includes('z')) return colorMap.z; return null; }; // Apply colors to all gizmo materials for (const mode of modes) { for (const group of groups) { const root = gizmoRoot[group] && gizmoRoot[group][mode]; if (!root) continue; root.traverse((obj) => { if (!obj || !obj.material) return; const color = getColorForHandle(obj.name); if (color === null) return; // Update the material color and store it as the base color obj.material.color.setHex(color); obj.material._color = obj.material.color.clone(); obj.material._opacity = obj.material.opacity; }); } } // Patch the gizmo's updateMatrixWorld to use custom highlight color if (!gizmoRoot._originalUpdateMatrixWorld) { gizmoRoot._originalUpdateMatrixWorld = gizmoRoot.updateMatrixWorld.bind(gizmoRoot); gizmoRoot._customHighlightColor = colorMap.highlight; gizmoRoot.updateMatrixWorld = function (force) { // Call original update first this._originalUpdateMatrixWorld(force); // Override the highlight color if an axis is selected if (this.enabled && this.axis) { const modes = ['translate', 'rotate', 'scale']; const groups = ['gizmo', 'helper']; for (const mode of modes) { for (const group of groups) { const root = this[group] && this[group][mode]; if (!root) continue; root.traverse((obj) => { if (!obj || !obj.material) return; // Check if this handle should be highlighted const shouldHighlight = obj.name === this.axis || this.axis.split('').some((a) => obj.name === a); if (shouldHighlight) { // Apply custom highlight color obj.material.color.setHex(this._customHighlightColor); obj.material.opacity = 1.0; } }); } } } }; } else { // Update the stored highlight color if already patched gizmoRoot._customHighlightColor = colorMap.highlight; } }; function patchNegativeAxisHandlesOnTransformControlsGizmos( controls: THREE_ADDONS.TransformControls ) { const gizmo = (controls as any)._gizmo; if (!gizmo) return; // Helper function to remove specific children from gizmo groups function removeNegativeHandles(gizmoGroup, mode) { if (!gizmoGroup || !gizmoGroup.children) return; const toRemove: Array = []; const seenAxes = { X: [], Y: [], Z: [] }; // First pass: catalog all children by axis gizmoGroup.children.forEach((child) => { if (child.name === 'X' || child.name === 'Y' || child.name === 'Z') { seenAxes[child.name].push(child); } }); // Second pass: mark negative handles for removal Object.keys(seenAxes).forEach((axisName) => { const axisChildren = seenAxes[axisName]; if (mode === 'translate' && axisChildren.length >= 2) { // In translate: [positive arrow at index 0, negative arrow at index 1, line at index 2] // Remove the negative arrow (index 1) toRemove.push(axisChildren[1]); } else if (mode === 'scale' && axisChildren.length >= 3) { // In scale: [negative cube at index 0, line at index 1, positive cube at index 2] // Remove the negative cube (index 2) toRemove.push(axisChildren[0]); } }); // Remove marked children toRemove.forEach((child) => { gizmoGroup.remove(child); if (child.geometry) child.geometry.dispose(); if (child.material && Array.isArray(child.material)) { child.material.forEach((material) => material.dispose()); } else if (child.material) { child.material.dispose(); } }); } // Only process the visual gizmos, NOT the pickers (which handle interaction) if (gizmo.gizmo && gizmo.gizmo['translate']) { removeNegativeHandles(gizmo.gizmo['translate'], 'translate'); } if (gizmo.gizmo && gizmo.gizmo['scale']) { removeNegativeHandles(gizmo.gizmo['scale'], 'scale'); } // Keep the pickers intact for interaction - they're invisible anyway } const getSvgIconUrl = (game: RuntimeGame, resourceName: string) => { const resource = game.getResourceLoader().getResource(resourceName); if (!resource) return ''; return game.getResourceLoader().getFullUrl(resource.file); }; const LEFT_KEY = 37; const UP_KEY = 38; const RIGHT_KEY = 39; const DOWN_KEY = 40; const ALT_KEY = 18; const DEL_KEY = 46; const BACKSPACE_KEY = 8; const LEFT_ALT_KEY = gdjs.InputManager.getLocationAwareKeyCode(ALT_KEY, 1); const RIGHT_ALT_KEY = gdjs.InputManager.getLocationAwareKeyCode(ALT_KEY, 2); const SHIFT_KEY = 16; const LEFT_SHIFT_KEY = gdjs.InputManager.getLocationAwareKeyCode( SHIFT_KEY, 1 ); const RIGHT_SHIFT_KEY = gdjs.InputManager.getLocationAwareKeyCode( SHIFT_KEY, 2 ); const SPACE_KEY = 32; const CTRL_KEY = 17; const LEFT_CTRL_KEY = gdjs.InputManager.getLocationAwareKeyCode(CTRL_KEY, 1); const RIGHT_CTRL_KEY = gdjs.InputManager.getLocationAwareKeyCode(CTRL_KEY, 2); const LEFT_META_KEY = gdjs.InputManager.getLocationAwareKeyCode(91, 1); const RIGHT_META_KEY = gdjs.InputManager.getLocationAwareKeyCode(93, 2); const W_KEY = 87; const A_KEY = 65; const C_KEY = 67; const S_KEY = 83; const D_KEY = 68; const Q_KEY = 81; const E_KEY = 69; const F_KEY = 70; const O_KEY = 79; const V_KEY = 86; const X_KEY = 88; const Y_KEY = 89; const Z_KEY = 90; const ESC_KEY = 27; const EQUAL_KEY = 187; const MINUS_KEY = 189; const KEY_DIGIT_1 = 49; const KEY_DIGIT_2 = 50; const KEY_DIGIT_3 = 51; const ROTATION_SNAP_DEGREES = 45; const exceptionallyGetKeyCodeFromLocationAwareKeyCode = ( locationAwareKeyCode: number ): number => { return locationAwareKeyCode % 1000; }; // See same factors in `newIDE/app/src/Utils/ZoomUtils.js`. const zoomInFactor = Math.pow(2, (2 * 1) / 16); const zoomOutFactor = Math.pow(2, (-2 * 1) / 16); const instanceStateFlag = { selected: 1, locked: 2, hovered: 4, }; const instanceWireframeColor = { [instanceStateFlag.hovered]: '#54daff', [instanceStateFlag.selected]: '#f2a63c', [instanceStateFlag.selected | instanceStateFlag.hovered]: '#ffd200', [instanceStateFlag.locked | instanceStateFlag.hovered]: '#b02715', [instanceStateFlag.locked | instanceStateFlag.selected]: '#b87f7f', [instanceStateFlag.locked | instanceStateFlag.selected | instanceStateFlag.hovered]: '#f51e02', }; const editorCameraFov = 45; /** @category In-Game Editor */ export type InGameEditorSettings = { theme: { iconButtonSelectedBackgroundColor: string; iconButtonSelectedColor: string; toolbarBackgroundColor: string; toolbarSeparatorColor: string; textColorPrimary: string; }; }; const defaultInGameEditorSettings: InGameEditorSettings = { theme: { iconButtonSelectedBackgroundColor: 'black', iconButtonSelectedColor: 'black', toolbarBackgroundColor: 'black', toolbarSeparatorColor: 'black', textColorPrimary: 'black', }, }; let hasWindowFocus = true; if (typeof window !== 'undefined') { window.addEventListener('focus', () => { hasWindowFocus = true; }); window.addEventListener('blur', () => { hasWindowFocus = false; }); } function isDefined(value: T | null | undefined): value is NonNullable { return value !== null && value !== undefined; } type Point3D = [float, float, float]; type RuntimeObjectWith3D = RuntimeObject & Base3DHandler & Resizable & Scalable & Flippable & { getCenterZInScene(): float; }; const is3D = (object: gdjs.RuntimeObject): object is RuntimeObjectWith3D => { return gdjs.Base3DHandler.is3D(object); }; type AABB3D = { min: Point3D; max: Point3D; }; const defaultEffectsData: EffectData[] = [ { effectType: 'Scene3D::HemisphereLight', name: 'Default Light for in-game editor', doubleParameters: { elevation: 45, intensity: 1, rotation: 0 }, stringParameters: { groundColor: '64;64;64', skyColor: '255;255;255', top: 'Y-', }, booleanParameters: {}, }, ]; // TODO: factor this? const isMacLike = typeof navigator !== 'undefined' && navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) ? true : false; const isControlOrCmdPressed = (inputManager: gdjs.InputManager) => { // On macOS, meta key (Apple/Command key) acts as Control key on Windows/Linux. return ( inputManager.isKeyPressed(LEFT_CTRL_KEY) || inputManager.isKeyPressed(RIGHT_CTRL_KEY) || inputManager.isKeyPressed(LEFT_META_KEY) || inputManager.isKeyPressed(RIGHT_META_KEY) ); }; const isAltPressed = (inputManager: gdjs.InputManager) => { return ( inputManager.isKeyPressed(LEFT_ALT_KEY) || inputManager.isKeyPressed(RIGHT_ALT_KEY) ); }; const isShiftPressed = (inputManager: gdjs.InputManager) => { return ( inputManager.isKeyPressed(LEFT_SHIFT_KEY) || inputManager.isKeyPressed(RIGHT_SHIFT_KEY) ); }; const isControlPressedOnly = (inputManager: gdjs.InputManager) => { return ( isControlOrCmdPressed(inputManager) && !isShiftPressed(inputManager) && !isAltPressed(inputManager) ); }; const isControlPlusShiftPressedOnly = (inputManager: gdjs.InputManager) => { return ( isControlOrCmdPressed(inputManager) && isShiftPressed(inputManager) && !isAltPressed(inputManager) ); }; const isSpacePressed = (inputManager: gdjs.InputManager) => inputManager.isKeyPressed(SPACE_KEY); const shouldDeleteSelection = (inputManager: gdjs.InputManager) => { return ( inputManager.isKeyPressed(DEL_KEY) || inputManager.isKeyPressed(BACKSPACE_KEY) ); }; const shouldScrollHorizontally = isAltPressed; const shouldZoom = (inputManager: gdjs.InputManager) => { // Browsers trigger a wheel event with ctrlKey or metaKey to true when the user // does a pinch gesture on a trackpad. If this is the case, we zoom. // see https://dev.to/danburzo/pinch-me-i-m-zooming-gestures-in-the-dom-a0e if (isControlOrCmdPressed(inputManager)) return true; if (isMacLike) { return isControlOrCmdPressed(inputManager); } else { return ( !isControlOrCmdPressed(inputManager) && !isAltPressed(inputManager) && !isShiftPressed(inputManager) ); } }; const pressAndReleaseForClickDuration = 200; const timeBetweenClicksForDoubleClick = 400; /** Get the identifiers of the touches that are currently active, without the mouse. */ const getCurrentTouchIdentifiers = (inputManager: gdjs.InputManager) => { return inputManager .getAllTouchIdentifiers() .slice() .filter((id) => id !== gdjs.InputManager.MOUSE_TOUCH_ID) // Exclude mouse touch .sort((a, b) => a - b); // Ensure stable order to help comparisons. }; const getTouchesCentroid = (inputManager: gdjs.InputManager) => { const ids = getCurrentTouchIdentifiers(inputManager); if (ids.length === 0) return { x: 0, y: 0 }; let sx = 0; let sy = 0; for (let i = 0; i < ids.length; i++) { sx += inputManager.getTouchX(ids[i]); sy += inputManager.getTouchY(ids[i]); } return { x: sx / ids.length, y: sy / ids.length }; }; const getTouchesDistance = (inputManager: gdjs.InputManager) => { const ids = getCurrentTouchIdentifiers(inputManager); if (ids.length === 0) return 0; return Math.hypot( inputManager.getTouchX(ids[0]) - inputManager.getTouchX(ids[1]), inputManager.getTouchY(ids[0]) - inputManager.getTouchY(ids[1]) ); }; const areSameTouchesSet = (ids1: Array, ids2: Array) => { if (ids1.length !== ids2.length) return false; for (let i = 0; i < ids1.length; i++) { if (ids1[i] !== ids2[i]) return false; } return true; }; const freeCameraKeys = [ LEFT_KEY, RIGHT_KEY, UP_KEY, DOWN_KEY, W_KEY, S_KEY, A_KEY, D_KEY, Q_KEY, E_KEY, ]; const shouldSwitchToFreeCamera = (inputManager: gdjs.InputManager) => !isControlOrCmdPressed(inputManager) && !isAltPressed(inputManager) && !isShiftPressed(inputManager) && freeCameraKeys.some((key) => inputManager.isKeyPressed(key)); const snap = (value: float, size: float, offset: float) => size ? offset + size * Math.round((value - offset) / size) : value; class Selection { private _selectedObjects: Array = []; add(object: gdjs.RuntimeObject) { if (!this._selectedObjects.includes(object)) { this._selectedObjects.push(object); } } addAll(objects: RuntimeObject[]) { for (const object of objects) { this.add(object); } } clear() { this._selectedObjects = []; } toggle(object: gdjs.RuntimeObject) { const index = this._selectedObjects.indexOf(object); if (index < 0) { this._selectedObjects.push(object); } else { this._selectedObjects.splice(index, 1); } } getSelectedObjects() { return this._selectedObjects; } getLastSelectedObject(options?: { ignoreIf: (object: gdjs.RuntimeObject) => boolean; }): gdjs.RuntimeObject | null { if (options && options.ignoreIf) { for (let i = this._selectedObjects.length - 1; i >= 0; i--) { const object = this._selectedObjects[i]; if (!options.ignoreIf(object)) { return object; } } return null; } return this._selectedObjects[this._selectedObjects.length - 1] || null; } getAABB(): AABB3D | null { let aabb: AABB3D | null = null; for (const object of this._selectedObjects) { if (is3D(object)) { const aabb2D = object.getAABB(); const minZ = object.getUnrotatedAABBMinZ(); const maxZ = object.getUnrotatedAABBMaxZ(); if (aabb) { aabb.min[0] = Math.min(aabb.min[0], aabb2D.min[0]); aabb.min[1] = Math.min(aabb.min[1], aabb2D.min[1]); aabb.min[2] = Math.min(aabb.min[2], minZ); aabb.max[0] = Math.max(aabb.max[0], aabb2D.max[0]); aabb.max[1] = Math.max(aabb.max[1], aabb2D.max[1]); aabb.max[2] = Math.max(aabb.max[2], maxZ); } else { aabb = { min: [aabb2D.min[0], aabb2D.min[1], minZ], max: [aabb2D.max[0], aabb2D.max[1], maxZ], }; } } } return aabb; } } class ObjectMover { editor: InGameEditor; _changeHappened = false; constructor(editor: InGameEditor) { this.editor = editor; } _objectInitialPositions: Map< gdjs.RuntimeObject, { x: float; y: float; z: float; rotationX: float; rotationY: float; angle: float; width: float; height: float; depth: float; } > = new Map(); startMove() { this._changeHappened = false; this._objectInitialPositions.clear(); } endMove(): boolean { const changeHappened = this._changeHappened; this._objectInitialPositions.clear(); this._changeHappened = false; return changeHappened; } move( selectedObjects: Array, movement: { translationX: float; translationY: float; translationZ: float; rotationX: float; rotationY: float; rotationZ: float; scaleX: float; scaleY: float; scaleZ: float; } ) { selectedObjects.forEach((object) => { if (this.editor.isInstanceLocked(object)) { return; } this._changeHappened = this._changeHappened || movement.translationX !== 0 || movement.translationY !== 0 || movement.translationZ !== 0 || movement.rotationX !== 0 || movement.rotationY !== 0 || movement.rotationZ !== 0 || movement.scaleX !== 1 || movement.scaleY !== 1 || movement.scaleZ !== 1; let initialPosition = this._objectInitialPositions.get(object); if (!initialPosition) { initialPosition = is3D(object) ? { x: object.getX(), y: object.getY(), z: object.getZ(), rotationX: object.getRotationX(), rotationY: object.getRotationY(), angle: object.getAngle(), width: object.getWidth(), height: object.getHeight(), depth: object.getDepth(), } : { x: object.getX(), y: object.getY(), z: 0, rotationX: 0, rotationY: 0, angle: object.getAngle(), width: object.getWidth(), height: object.getHeight(), depth: 0, }; this._objectInitialPositions.set(object, initialPosition); } // TODO Remove this when we handle 2D instances controls if (!is3D(object)) { return; } object.setX(Math.round(initialPosition.x + movement.translationX)); object.setY(Math.round(initialPosition.y + movement.translationY)); object.setAngle( gdjs.evtTools.common.mod( Math.round(initialPosition.angle + movement.rotationZ), 360 ) ); if (movement.scaleX !== 1) { object.setWidth( Math.round(initialPosition.width * Math.abs(movement.scaleX)) ); } if (movement.scaleY !== 1) { object.setHeight( Math.round(initialPosition.height * Math.abs(movement.scaleY)) ); } if (is3D(object)) { object.setZ(Math.round(initialPosition.z + movement.translationZ)); object.setRotationX( gdjs.evtTools.common.mod( Math.round(initialPosition.rotationX + movement.rotationX), 360 ) ); object.setRotationY( gdjs.evtTools.common.mod( Math.round(initialPosition.rotationY + movement.rotationY), 360 ) ); if (movement.scaleZ !== 1) { object.setDepth( Math.round(initialPosition.depth * Math.abs(movement.scaleZ)) ); } } }); } } const getCameraForwardVector = (threeCamera: THREE.Camera) => { // Make sure camera's matrixWorld is up-to-date (usually is, but good practice). threeCamera.updateMatrixWorld(); // threeCamera.matrixWorld is a 4x4. In Three.js, the columns correspond to: // [ right.x, up.x, forwardNeg.x, pos.x // right.y, up.y, forwardNeg.y, pos.y // right.z, up.z, forwardNeg.z, pos.z // 0, 0, 0, 1 ] // // By default, a Three.js camera looks down the -Z axis, so the "forward" axis // in the matrix is actually the negative Z column. We'll call it "forward" below. const elements = threeCamera.matrixWorld.elements; // Local forward axis in world space (note we take the negative of that column). const forward = new THREE.Vector3(-elements[8], elements[9], -elements[10]); // Normalize it, just in case (they should generally be unit vectors). forward.normalize(); return { forward }; }; /** @category In-Game Editor */ export class InGameEditor { private _editorId: string = ''; private _runtimeGame: RuntimeGame; private _currentScene: gdjs.RuntimeScene | null = null; private _editedInstanceContainer: gdjs.RuntimeInstanceContainer | null = null; private _editedInstanceDataList: InstanceData[] = []; private _editedLayerDataList: LayerData[] = []; private _selectedLayerName: string = ''; private _innerArea: AABB3D | null = null; private _threeInnerArea: THREE.Object3D | null = null; private _unregisterContextLostListener: (() => void) | null = null; private _tempVector2d: THREE.Vector2 = new THREE.Vector2(); private _raycaster: THREE.Raycaster = new THREE.Raycaster(); private _isVisible = true; private _timeSinceLastInteraction = 0; private _isFirstFrame = true; private _editorCamera; /** Keep track of the focus to know if the game was blurred since the last frame. */ private _windowHadFocus = true; // The controls shown to manipulate the selection. private _selectionControls: { object: gdjs.RuntimeObject; dummyThreeObject: THREE.Object3D; threeTransformControls: THREE_ADDONS.TransformControls; } | null = null; private _transformControlsMode: 'translate' | 'rotate' | 'scale' = 'translate'; private _editorGrid: EditorGrid; private _selectionControlsMovementTotalDelta: { translationX: float; translationY: float; translationZ: float; rotationX: float; rotationY: float; rotationZ: float; scaleX: float; scaleY: float; scaleZ: float; } | null = null; private _hasSelectionActuallyMoved = false; private _isTransformControlsHovered = false; private _wasMovingSelectionLastFrame = false; private _selectionBox: THREE_ADDONS.SelectionBox | null = null; private _selectionBoxElement: HTMLDivElement; private _selectionBoxStartCursorX: float = 0; private _selectionBoxStartCursorY: float = 0; // The selected objects. private _selection = new Selection(); private _selectionBoxes: Map = new Map(); private _objectMover = new ObjectMover(this); private _wasMouseLeftButtonPressed = false; private _wasMouseRightButtonPressed = false; private _wasMouseMiddleButtonPressed = false; private _pressedOriginalCursorX: float = 0; private _pressedOriginalCursorY: float = 0; private _previousCursorX: float = 0; private _previousCursorY: float = 0; private _pressedRightButtonTime: number = 0; private _pressedMiddleButtonTime: number = 0; private _lastClickOnObjectUnderCursor: { object: gdjs.RuntimeObject | null; time: number; } = { object: null, time: 0, }; // Dragged new object: private _draggedNewObject: gdjs.RuntimeObject | null = null; private _draggedSelectedObject: gdjs.RuntimeObject | null = null; private _draggedSelectedObjectInitialX: float = 0; private _draggedSelectedObjectInitialY: float = 0; private _draggedSelectedObjectInitialZ: float = 0; private _draggedSelectedObjectTotalDelta: { translationX: float; translationY: float; translationZ: float; rotationX: float; rotationY: float; rotationZ: float; scaleX: float; scaleY: float; scaleZ: float; } = { translationX: 0, translationY: 0, translationZ: 0, rotationX: 0, rotationY: 0, rotationZ: 0, scaleX: 1, scaleY: 1, scaleZ: 1, }; private _instancesEditorSettings: InstancesEditorSettings | null = null; private _toolbar: Toolbar; private _inGameEditorSettings: InGameEditorSettings; constructor( game: RuntimeGame, projectData: ProjectData, inGameEditorSettings: InGameEditorSettings | null ) { this._runtimeGame = game; this._editorCamera = new EditorCamera(this); this._editorGrid = new EditorGrid(this); this._selectionBoxElement = document.createElement('div'); this._selectionBoxElement.style.position = 'fixed'; this._selectionBoxElement.style.backgroundColor = '#f2a63c44'; this._selectionBoxElement.style.border = '1px solid #f2a63c'; this._inGameEditorSettings = inGameEditorSettings || defaultInGameEditorSettings; this._toolbar = new Toolbar({ getTransformControlsMode: () => this._getTransformControlsMode(), setTransformControlsMode: (mode: 'translate' | 'rotate' | 'scale') => this._setTransformControlsMode(mode), focusOnSelection: () => this._focusOnSelection(), switchToFreeCamera: () => this._getEditorCamera().switchToFreeCamera(), switchToOrbitCamera: () => this._getEditorCamera().switchToOrbitAroundZ0(4000), isFreeCamera: () => this._getEditorCamera().isFreeCamera(), getSvgIconUrl: (iconName: string) => getSvgIconUrl(game, iconName), hasSelectionControlsShown: () => !!this._selectionControls, }); this._applyInGameEditorSettings(); this.onProjectDataChange(projectData); // Uncomment to get access to the runtime game from the console and do // testing. // window.globalRuntimeGameForTesting = game; } private _setupWebGLContextLostListener(): void { const canvas = this._runtimeGame.getRenderer().getCanvas(); if (!canvas) return; const handleContextLost = (event: Event) => { console.warn('WebGL context lost, notifying the editor...'); // Prevent to restore the context, and prefer to let the editor handle this // to restart from a clean state. event.preventDefault(); const debuggerClient = this._runtimeGame._debuggerClient; if (debuggerClient) { debuggerClient.sendGraphicsContextLost(); } }; canvas.addEventListener('webglcontextlost', handleContextLost); canvas.addEventListener('contextlost', handleContextLost); this._unregisterContextLostListener = () => { canvas.removeEventListener('webglcontextlost', handleContextLost); canvas.removeEventListener('contextlost', handleContextLost); }; } dispose(): void { if (this._unregisterContextLostListener) { this._unregisterContextLostListener(); this._unregisterContextLostListener = null; } } private _applyInGameEditorSettings() { if (typeof document === 'undefined') return; const rootElement = document.documentElement; if (!rootElement) return; rootElement.style.setProperty( '--in-game-editor-theme-icon-button-selected-background-color', this._inGameEditorSettings.theme.iconButtonSelectedBackgroundColor ); rootElement.style.setProperty( '--in-game-editor-theme-icon-button-selected-color', this._inGameEditorSettings.theme.iconButtonSelectedColor ); rootElement.style.setProperty( '--in-game-editor-theme-toolbar-background-color', this._inGameEditorSettings.theme.toolbarBackgroundColor ); rootElement.style.setProperty( '--in-game-editor-theme-toolbar-separator-color', this._inGameEditorSettings.theme.toolbarSeparatorColor ); rootElement.style.setProperty( '--in-game-editor-theme-text-color-primary', this._inGameEditorSettings.theme.textColorPrimary ); } setInGameEditorSettings(inGameEditorSettings: InGameEditorSettings) { this._inGameEditorSettings = { ...this._inGameEditorSettings, ...inGameEditorSettings, }; this._applyInGameEditorSettings(); } getRuntimeGame() { return this._runtimeGame; } onProjectDataChange(projectData: ProjectData): void { this.setEffectsHiddenInEditor( !!projectData.properties.areEffectsHiddenInEditor ); } onLayersDataChange( layersData: Array, areEffectsHiddenInEditor: boolean ): void { for (const layerData of layersData) { // Camera controls don't work in orthographic. if (layerData.cameraType === 'orthographic') { layerData.cameraType = 'perspective'; } layerData.camera3DFieldOfView = editorCameraFov; // Force 2D and 3D objects to be visible on any layer. layerData.renderingType = '2d+3d'; if (areEffectsHiddenInEditor) { if (layerData.effects !== defaultEffectsData) { layerData._hiddenEffects = layerData.effects; layerData.effects = defaultEffectsData; } } else { if (layerData._hiddenEffects) { layerData.effects = layerData._hiddenEffects; } } } } /** * Modify the layer data accordingly. * `gdjs.HotReloader.hotReloadRuntimeSceneLayers` must be run for the * changes to be applied. */ setEffectsHiddenInEditor(areEffectsHiddenInEditor: boolean) { const projectData = this._runtimeGame.getGameData(); projectData.properties.areEffectsHiddenInEditor = areEffectsHiddenInEditor; for (const layoutData of projectData.layouts) { this.onLayersDataChange(layoutData.layers, areEffectsHiddenInEditor); } } areEffectsHidden(): boolean { return !!this._runtimeGame.getGameData().properties .areEffectsHiddenInEditor; } getEditorId(): string { return this._editorId; } getEditedInstanceDataList(): InstanceData[] { return this._editedInstanceDataList; } getEditedLayerDataList(): LayerData[] { return this._editedLayerDataList; } getEditedInstanceContainer(): gdjs.RuntimeInstanceContainer | null { return this._editedInstanceContainer; } getCurrentScene(): gdjs.RuntimeScene | null { return this._currentScene; } /** * Return the layer to be used for camera calculus. * @see getEditorLayer */ private getCameraLayer(layerName: string): gdjs.RuntimeLayer | null { // When the edited container is a custom object, // only a base layer exists and `getLayer` falls back on it. return this._currentScene ? this._currentScene.getLayer(layerName) : null; } /** * Return the layer which contains the objects. * @see getCameraLayer */ private getEditorLayer(layerName: string): gdjs.RuntimeLayer | null { return this._editedInstanceContainer ? this._editedInstanceContainer.getLayer(layerName) : null; } /** * Called by the RuntimeGame when the game resolution is changed. * Useful to notify scene and layers that resolution is changed, as they * might be caching it. */ onGameResolutionResized() { if (!this._currentScene) { return; } this._currentScene.onGameResolutionResized(); } async switchToSceneOrVariant( editorId: string | null, sceneName: string | null, externalLayoutName: string | null, eventsBasedObjectType: string | null, eventsBasedObjectVariantName: string | null, editorCamera3D: EditorCameraState | null ) { if (this._currentScene) { this._currentScene.unloadScene(); this._currentScene = null; } // The 3D scene is rebuilt and the inner area marker is lost in the process. this._threeInnerArea = null; this._innerArea = null; this._selectedLayerName = ''; // Clear any reference to `RuntimeObject` from the unloaded scene. this._selectionBoxes.clear(); this._selectionControls = null; this._draggedNewObject = null; this._draggedSelectedObject = null; const selectedObjectIds = this._selection .getSelectedObjects() .map((object) => object.persistentUuid) .filter(Boolean) as Array; let editedLayerDataList: Array = []; let editedInstanceDataList: Array = []; if (eventsBasedObjectType) { const eventsBasedObjectVariantData = this._runtimeGame.getEventsBasedObjectVariantData( eventsBasedObjectType, eventsBasedObjectVariantName || '' ); if (eventsBasedObjectVariantData) { editedLayerDataList = eventsBasedObjectVariantData.layers; editedInstanceDataList = eventsBasedObjectVariantData.instances; await this._runtimeGame._resourcesLoader.loadResources( eventsBasedObjectVariantData.usedResources.map( (resource) => resource.name ), () => {} ); const sceneAndCustomObject = this._createSceneWithCustomObject( eventsBasedObjectType, eventsBasedObjectVariantName || '' ); if (sceneAndCustomObject) { const { scene, customObjectInstanceContainer } = sceneAndCustomObject; this._currentScene = scene; this._editedInstanceContainer = customObjectInstanceContainer; } this.setInstancesEditorSettings( eventsBasedObjectVariantData.editionSettings ); this._innerArea = eventsBasedObjectVariantData._initialInnerArea; } else { console.warn( `Couldn't find any variant named "${eventsBasedObjectVariantName || ''}" for ${eventsBasedObjectType}` ); } } else if (sceneName) { await this._runtimeGame.loadFirstAssetsAndStartBackgroundLoading( sceneName, () => {} ); // Load the new one const sceneAndExtensionsData = this._runtimeGame.getSceneAndExtensionsData(sceneName); const newScene = new gdjs.RuntimeScene(this._runtimeGame); newScene.loadFromScene(sceneAndExtensionsData, { skipCreatingInstances: !!externalLayoutName, }); // Optionally create the objects from an external layout. if (externalLayoutName) { const externalLayoutData = this._runtimeGame.getExternalLayoutData(externalLayoutName); if (externalLayoutData) { newScene.createObjectsFrom( externalLayoutData.instances, 0, 0, 0, /*trackByPersistentUuid=*/ true ); this.setInstancesEditorSettings(externalLayoutData.editionSettings); } } else { this.setInstancesEditorSettings( sceneAndExtensionsData!.sceneData.uiSettings ); } this._currentScene = newScene; this._editedInstanceContainer = newScene; if (sceneAndExtensionsData) { editedLayerDataList = sceneAndExtensionsData.sceneData.layers; } if (externalLayoutName) { const externalLayoutData = this._runtimeGame.getExternalLayoutData(externalLayoutName); if (externalLayoutData) { editedInstanceDataList = externalLayoutData.instances; } } else { const sceneAndExtensionsData = this._runtimeGame.getSceneAndExtensionsData(sceneName); if (sceneAndExtensionsData) { editedInstanceDataList = sceneAndExtensionsData.sceneData.instances; } } } else { console.warn('eventsBasedObjectType or sceneName must be set.'); } this._editedInstanceDataList = editedInstanceDataList; this._editedLayerDataList = editedLayerDataList; this._editorId = editorId || ''; if (editorCamera3D) { this.restoreCameraState(editorCamera3D); } else { // TODO Get the visibleScreenArea from the editor. this.zoomToInitialPosition({ minX: 0.15, minY: 0.15, maxX: 0.85, maxY: 0.85, }); } // Update initialRuntimeGameStatus so that a hard reload // will come back to the same state, and so that we can check later // if the game is already on the state that is being requested. this._runtimeGame.getAdditionalOptions().initialRuntimeGameStatus = { isPaused: this._runtimeGame.isPaused(), isInGameEdition: this._runtimeGame.isInGameEdition(), sceneName: sceneName, injectedExternalLayoutName: externalLayoutName, skipCreatingInstancesFromScene: !!externalLayoutName, eventsBasedObjectType, eventsBasedObjectVariantName, editorId, }; // Try to keep object selection in case the same scene is reloaded. this.setSelectedObjects(selectedObjectIds); this._isFirstFrame = true; } private _createSceneWithCustomObject( eventsBasedObjectType: string, eventsBasedObjectVariantName: string ): { scene: gdjs.RuntimeScene; customObjectInstanceContainer: gdjs.CustomRuntimeObjectInstanceContainer; } | null { const eventsBasedObjectData = this._runtimeGame.getEventsBasedObjectData( eventsBasedObjectType ); if (!eventsBasedObjectData) { logger.error( `A CustomRuntimeObject was open in editor referring to an non existing events based object data with type "${eventsBasedObjectType}".` ); return null; } const scene = new gdjs.RuntimeScene(this._runtimeGame); scene.loadFromScene({ sceneData: { variables: [], instances: [ { angle: 0, customSize: false, height: 0, layer: '', name: 'Object', persistentUuid: '12345678-1234-1234-1234-123456789abc', width: 0, x: 0, y: 0, zOrder: 1, numberProperties: [], stringProperties: [], initialVariables: [], locked: false, }, ], objects: [ { name: 'Object', type: eventsBasedObjectType, //@ts-ignore variant: eventsBasedObjectVariantName, content: {}, variables: [], // Add all capabilities just in case events need them. behaviors: [ { name: 'Animation', type: 'AnimatableCapability::AnimatableBehavior', }, { name: 'Effect', type: 'EffectCapability::EffectBehavior' }, { name: 'Flippable', type: 'FlippableCapability::FlippableBehavior', }, { name: 'Object3D', type: 'Scene3D::Base3DBehavior', }, { name: 'Opacity', type: 'OpacityCapability::OpacityBehavior', }, { name: 'Resizable', type: 'ResizableCapability::ResizableBehavior', }, { name: 'Scale', type: 'ScalableCapability::ScalableBehavior', }, { name: 'Text', type: 'TextContainerCapability::TextContainerBehavior', }, ], effects: [], }, ], layers: [ { ambientLightColorB: 200, ambientLightColorG: 200, ambientLightColorR: 200, camera3DFarPlaneDistance: 10000, camera3DFieldOfView: 45, camera3DNearPlaneDistance: 3, followBaseLayerCamera: false, isLightingLayer: false, name: '', renderingType: '2d+3d', visibility: true, cameras: [ { defaultSize: true, defaultViewport: true, height: 0, viewportBottom: 1, viewportLeft: 0, viewportRight: 1, viewportTop: 0, width: 0, }, ], effects: [ { effectType: 'Scene3D::HemisphereLight', name: '3D Light', doubleParameters: { elevation: 45, intensity: 1, rotation: 0, }, stringParameters: { groundColor: '64;64;64', skyColor: '255;255;255', top: 'Y-', }, booleanParameters: {}, }, ], }, ], r: 32, v: 32, b: 32, mangledName: 'FakeSceneForCustomObject', name: eventsBasedObjectData.name, stopSoundsOnStartup: true, title: '', behaviorsSharedData: [ { name: 'Text', type: 'TextContainerCapability::TextContainerBehavior', }, ], usedResources: [], }, usedExtensionsWithVariablesData: this._runtimeGame.getGameData().eventsFunctionsExtensions, }); const objects = scene.getObjects('Object'); const object = objects ? objects[0] : null; if (!object) { return null; } const customObject = object as gdjs.CustomRuntimeObject; if (!customObject._instanceContainer) { return null; } return { scene, customObjectInstanceContainer: customObject._instanceContainer, }; } updateInnerArea( areaMinX: float, areaMinY: float, areaMinZ: float, areaMaxX: float, areaMaxY: float, areaMaxZ: float ) { if (!this._innerArea) { return; } // This only works because `this._innerArea` is the same instance as the // one used by custom object instances. this._innerArea.min[0] = areaMinX; this._innerArea.min[1] = areaMinY; this._innerArea.min[2] = areaMinZ; this._innerArea.max[0] = areaMaxX; this._innerArea.max[1] = areaMaxY; this._innerArea.max[2] = areaMaxZ; } setSelectedLayerName(layerName: string): void { this._selectedLayerName = layerName; } setInstancesEditorSettings( instancesEditorSettings: InstancesEditorSettings ) { this._instancesEditorSettings = instancesEditorSettings; this._editorGrid.setSettings(instancesEditorSettings); } updateInstancesEditorSettings( instancesEditorSettings: InstancesEditorSettings ) { if (this._instancesEditorSettings) { Object.assign(this._instancesEditorSettings, instancesEditorSettings); } else { this._instancesEditorSettings = instancesEditorSettings; } this._editorGrid.setSettings(instancesEditorSettings); } private _getTempVector2d(x: float, y: float): THREE.Vector2 { this._tempVector2d.x = x; this._tempVector2d.y = y; return this._tempVector2d; } zoomToInitialPosition(visibleScreenArea: { minX: number; minY: number; maxX: number; maxY: number; }) { if (this._innerArea) { this.zoomToFitArea( { minX: this._innerArea.min[0], minY: this._innerArea.min[1], minZ: this._innerArea.min[2], maxX: this._innerArea.max[0], maxY: this._innerArea.max[1], maxZ: this._innerArea.max[2], }, visibleScreenArea, 0.1 ); } else { this.zoomToFitArea( { minX: 0, minY: 0, minZ: 0, maxX: this._runtimeGame.getOriginalWidth(), maxY: this._runtimeGame.getOriginalHeight(), maxZ: 0, }, visibleScreenArea, 0.1 ); } } zoomToFitContent(visibleScreenArea: { minX: number; minY: number; maxX: number; maxY: number; }) { const editedInstanceContainer = this.getEditedInstanceContainer(); if (!editedInstanceContainer) return; this.zoomToFitObjects( editedInstanceContainer.getAdhocListOfAllInstances(), visibleScreenArea, 0.01 ); } zoomToFitSelection(visibleScreenArea: { minX: number; minY: number; maxX: number; maxY: number; }) { this.zoomToFitObjects( this._selection.getSelectedObjects(), visibleScreenArea, 0.2 ); } zoomToFitObjects( objects: Array, visibleScreenArea: { minX: number; minY: number; maxX: number; maxY: number; }, margin: float ) { this._getEditorCamera().zoomToFitObjects( objects, visibleScreenArea, margin ); } zoomToFitArea( sceneArea: { minX: number; minY: number; minZ: number; maxX: number; maxY: number; maxZ: number; }, visibleScreenArea: { minX: number; minY: number; maxX: number; maxY: number; }, margin: float ) { if (!this._currentScene) return; this._getEditorCamera().zoomToFitArea( sceneArea, visibleScreenArea, margin ); this._getEditorCamera().switchToFreeCamera(); } zoomBy(zoomFactor: float) { if (!this._currentScene) return; this._getEditorCamera().zoomBy(zoomFactor); } setZoom(zoom: float) { if (!this._currentScene) return; this._getEditorCamera().setZoom(zoom); } getSelectionAABB(): AABB3D | null { return this._selection.getAABB(); } setSelectedObjects(persistentUuids: Array) { const editedInstanceContainer = this.getEditedInstanceContainer(); if (!editedInstanceContainer) return; const persistentUuidsSet = new Set(persistentUuids); const selectedObjectsMap = new Map(); for (const object of editedInstanceContainer.getAdhocListOfAllInstances()) { if ( object.persistentUuid && persistentUuidsSet.has(object.persistentUuid) ) { // We can't add the object to the selection directly because they // would be out of order. selectedObjectsMap.set(object.persistentUuid, object); } } this._selection.clear(); for (const instanceUuid of persistentUuids) { const object = selectedObjectsMap.get(instanceUuid); if (object) { this._selection.add(object); } } // Send back default instances sizes. this._sendSelectionUpdate({ isSendingBackSelectionForDefaultSize: true }); } centerViewOnLastSelectedInstance() { if (!this._currentScene) return; const object = this._selection.getLastSelectedObject(); if (!object) { return; } this._getEditorCamera().switchToOrbitAroundObject(object); } private _focusOnSelection() { // TODO Use the center of the AABB of the whole selection instead const selectedObject = this._selection.getLastSelectedObject(); if (!selectedObject) { return; } this._getEditorCamera().switchToOrbitAroundObject(selectedObject); } private _handleCameraMovement() { const inputManager = this._runtimeGame.getInputManager(); const currentScene = this._currentScene; if (!currentScene) return; const selectedObject = this._selection.getLastSelectedObject(); if (inputManager.isKeyPressed(F_KEY) && selectedObject) { this._focusOnSelection(); } if ( !this._getEditorCamera().isFreeCamera() && shouldSwitchToFreeCamera(inputManager) ) { this._getEditorCamera().switchToFreeCamera(); } this._getEditorCamera().step(); const layerNames = []; currentScene.getAllLayerNames(layerNames); layerNames.forEach((layerName) => { const layer = currentScene.getLayer(layerName); this._getEditorCamera().updateCamera(currentScene, layer); }); } moveSelectionUnderCursor() { if (!this._currentScene) return; const cursor = this._getCursorIn3D(this._selection.getSelectedObjects()); if (!cursor) { return; } const [cursorX, cursorY, cursorZ] = cursor; let minX = Number.MAX_VALUE; let minY = Number.MAX_VALUE; let minZ = Number.MAX_VALUE; let maxX = Number.MIN_VALUE; let maxY = Number.MIN_VALUE; for (const object of this._selection.getSelectedObjects()) { minX = Math.min(minX, object.getAABBLeft()); minY = Math.min(minY, object.getAABBTop()); if (is3D(object)) { minZ = Math.min(minZ, object.getUnrotatedAABBMinZ()); } maxX = Math.max(maxX, object.getAABBRight()); maxY = Math.max(maxY, object.getAABBBottom()); } const deltaX = cursorX - (maxX + minX) / 2; const deltaY = cursorY - (maxY + minY) / 2; const deltaZ = cursorZ - minZ; for (const object of this._selection.getSelectedObjects()) { object.setX(object.getX() + deltaX); object.setY(object.getY() + deltaY); if (is3D(object)) { object.setZ(object.getZ() + deltaZ); } } this._sendSelectionUpdate({ hasSelectedObjectBeenModified: true }); } private _shouldDragSelectedObject(): boolean { const inputManager = this._runtimeGame.getInputManager(); return ( isControlOrCmdPressed(inputManager) && (!this._selectionControls || !this._selectionControls.threeTransformControls.dragging) ); } private _handleSelectedObjectDragging(): void { const inputManager = this._runtimeGame.getInputManager(); // Always check first if we should end an existing drag. if ( this._draggedSelectedObject && (inputManager.isMouseButtonReleased(0) || !this._shouldDragSelectedObject()) ) { this._draggedSelectedObject = null; const changeHappened = this._objectMover.endMove(); this._sendSelectionUpdate({ hasSelectedObjectBeenModified: changeHappened, }); } // Inspect then if a drag should be started or continued. if (!this._shouldDragSelectedObject()) { // We can early return as the rest is not applicable (we've already checked // if a drag should be ended). return; } if (!this._currentScene) return; const editedInstanceContainer = this.getEditedInstanceContainer(); if (!editedInstanceContainer) return; if ( inputManager.isMouseButtonPressed(0) && !this._draggedSelectedObject ) { // Start a new drag. let object = this.getObjectUnderCursor(); if (object && this._selection.getSelectedObjects().includes(object)) { if (isControlOrCmdPressed(inputManager)) { object = this._duplicateSelectedObjects(object); if (!object) { return; } } this._draggedSelectedObject = object; this._draggedSelectedObjectInitialX = object.getX(); this._draggedSelectedObjectInitialY = object.getY(); this._draggedSelectedObjectInitialZ = is3D(object) ? object.getZ() : 0; this._objectMover.startMove(); } } // Continue an existing drag. if (!this._draggedSelectedObject) { return; } let isIntersectionFound = false; let intersectionX: float = 0; let intersectionY: float = 0; let intersectionZ: float = 0; if (is3D(this._draggedSelectedObject)) { const cursor = this._getCursorIn3D( this._selection.getSelectedObjects() ); if (cursor) { isIntersectionFound = true; [intersectionX, intersectionY, intersectionZ] = cursor; } } else { const projectedCursor = this._getProjectedCursor(); if (projectedCursor) { isIntersectionFound = true; [intersectionX, intersectionY] = projectedCursor; } } if (isIntersectionFound) { this._editorGrid.setNormal('Z'); this._editorGrid.setPosition( intersectionX, intersectionY, intersectionZ ); const cameraLayer = this.getCameraLayer( this._draggedSelectedObject.getLayer() ); const threeScene = cameraLayer ? cameraLayer.getRenderer().getThreeScene() : null; if (threeScene) { this._editorGrid.setTreeScene(threeScene); } this._editorGrid.setVisible(true); if (this._editorGrid.isSpanningEnabled(inputManager)) { intersectionX = this._editorGrid.getSnappedX(intersectionX); intersectionY = this._editorGrid.getSnappedY(intersectionY); } this._draggedSelectedObjectTotalDelta.translationX = intersectionX - this._draggedSelectedObjectInitialX; this._draggedSelectedObjectTotalDelta.translationY = intersectionY - this._draggedSelectedObjectInitialY; this._draggedSelectedObjectTotalDelta.translationZ = intersectionZ - this._draggedSelectedObjectInitialZ; } else { this._draggedSelectedObjectTotalDelta.translationX = 0; this._draggedSelectedObjectTotalDelta.translationY = 0; this._draggedSelectedObjectTotalDelta.translationZ = 0; } this._objectMover.move( this._selection.getSelectedObjects(), this._draggedSelectedObjectTotalDelta ); } private _duplicateSelectedObjects( objectUnderCursor: gdjs.RuntimeObject ): gdjs.RuntimeObject | null { const editedInstanceContainer = this.getEditedInstanceContainer(); if (!editedInstanceContainer) return null; let newObjectUnderCursor: gdjs.RuntimeObject | null = null; const addedObjects: Array = []; for (const selectedObject of this._selection.getSelectedObjects()) { const newObject = editedInstanceContainer.createObject( selectedObject.getName() ); if (!newObject) return null; newObject.persistentUuid = gdjs.makeUuid(); newObject.setLayer(selectedObject.getLayer()); newObject.setX(selectedObject.getX()); newObject.setY(selectedObject.getY()); newObject.setAngle(selectedObject.getAngle()); newObject.setWidth(selectedObject.getWidth()); newObject.setHeight(selectedObject.getHeight()); if (is3D(newObject) && is3D(selectedObject)) { newObject.setZ(selectedObject.getZ()); newObject.setRotationX(selectedObject.getRotationX()); newObject.setRotationY(selectedObject.getRotationY()); newObject.setDepth(selectedObject.getDepth()); } addedObjects.push(newObject); if (selectedObject === objectUnderCursor) { newObjectUnderCursor = newObject; } } this._selection.clear(); this._selection.addAll(addedObjects); this._sendSelectionUpdate({ addedObjects, }); return newObjectUnderCursor; } private _handleSelectionMovement() { // Finished moving the selection. if ( this._wasMovingSelectionLastFrame && !this._selectionControlsMovementTotalDelta ) { const changeHappened = this._objectMover.endMove(); this._sendSelectionUpdate({ hasSelectedObjectBeenModified: changeHappened, }); } // Start moving the selection. if ( !this._wasMovingSelectionLastFrame && this._selectionControlsMovementTotalDelta ) { this._objectMover.startMove(); } // Move the selection. if (this._selectionControlsMovementTotalDelta) { this._objectMover.move( this._selection.getSelectedObjects(), this._selectionControlsMovementTotalDelta ); } } private _updateSelectionBox() { const inputManager = this._runtimeGame.getInputManager(); const runtimeGame = this._runtimeGame; const threeRenderer = runtimeGame.getRenderer().getThreeRenderer(); if (!threeRenderer) return; const currentScene = this._currentScene; if (!currentScene) return; const editedInstanceContainer = this._editedInstanceContainer; if (!editedInstanceContainer) return; const cameraLayer = this.getCameraLayer(''); if (!cameraLayer) return; const runtimeLayerRender = cameraLayer.getRenderer(); const threeCamera = runtimeLayerRender.getThreeCamera(); const threeScene = runtimeLayerRender.getThreeScene(); if (!threeCamera || !threeScene) return; const cursorX = inputManager.getCursorX(); const cursorY = inputManager.getCursorY(); const touchIds = getCurrentTouchIdentifiers(inputManager); const hasMultipleTouches = touchIds.length >= 2; if ( inputManager.isMouseButtonPressed(0) && !this._shouldDragSelectedObject() && !isSpacePressed(inputManager) && !hasMultipleTouches ) { if (this._wasMouseLeftButtonPressed && this._selectionBox) { this._selectionBox.endPoint.set( this._getNormalizedScreenX(cursorX), this._getNormalizedScreenY(cursorY), 0.5 ); const minX = Math.min(this._selectionBoxStartCursorX, cursorX); const minY = Math.min(this._selectionBoxStartCursorY, cursorY); const maxX = Math.max(this._selectionBoxStartCursorX, cursorX); const maxY = Math.max(this._selectionBoxStartCursorY, cursorY); this._selectionBoxElement.style.left = minX + 'px'; this._selectionBoxElement.style.top = minY + 'px'; this._selectionBoxElement.style.width = maxX - minX + 'px'; this._selectionBoxElement.style.height = maxY - minY + 'px'; } else { this._selectionBox = new THREE_ADDONS.SelectionBox( threeCamera, threeScene ); this._selectionBox.startPoint.set( this._getNormalizedScreenX(cursorX), this._getNormalizedScreenY(cursorY), 0.5 ); const domElementContainer = runtimeGame .getRenderer() .getDomElementContainer(); if (domElementContainer) { this._selectionBoxElement.style.left = cursorX + 'px'; this._selectionBoxElement.style.top = cursorY + 'px'; this._selectionBoxElement.style.width = '0px'; this._selectionBoxElement.style.height = '0px'; domElementContainer.appendChild(this._selectionBoxElement); } this._selectionBoxStartCursorX = cursorX; this._selectionBoxStartCursorY = cursorY; } } if ( (inputManager.isMouseButtonReleased(0) || this._hasSelectionActuallyMoved) && this._selectionBox ) { if ( !this._selectionBox.endPoint.equals(this._selectionBox.startPoint) && !this._hasSelectionActuallyMoved && !hasMultipleTouches ) { // Selection rectangle ended. const objects = new Set(); for (const selectThreeObject of this._selectionBox.select()) { // TODO Select the object if all its meshes are inside the rectangle // instead of if any is. const object = this._getObject3D(selectThreeObject); if (object) { objects.add(object); } } if (!isShiftPressed(inputManager)) { this._selection.clear(); } const layer = this.getEditorLayer(this._selectedLayerName); if (layer && layer.isVisible() && !layer._initialLayerData.isLocked) { for (const object of objects) { if (!this.isInstanceSealed(object)) { this._selection.add(object); } } } this._sendSelectionUpdate(); } else { // Selection rectangle was discarded. } this._selectionBox = null; const domElementContainer = runtimeGame .getRenderer() .getDomElementContainer(); if (domElementContainer) { domElementContainer.removeChild(this._selectionBoxElement); } } } private _handleSelection({ objectUnderCursor, }: { objectUnderCursor: gdjs.RuntimeObject | null; }) { const editedInstanceContainer = this.getEditedInstanceContainer(); if (!editedInstanceContainer) return; const inputManager = this._runtimeGame.getInputManager(); if (shouldDeleteSelection(inputManager)) { const removedObjects = this._selection.getSelectedObjects(); removedObjects.forEach((object) => { object.deleteFromScene(); }); this._selection.clear(); this._sendSelectionUpdate({ removedObjects, }); } if (inputManager.wasKeyJustPressed(ESC_KEY)) { this._selection.clear(); this._sendSelectionUpdate(); } // Left click: select the object under the cursor. if ( !this._isTransformControlsHovered && inputManager.isMouseButtonReleased(0) && this._hasCursorStayedStillWhilePressed({ toleranceRadius: 10 }) ) { if (!isShiftPressed(inputManager)) { this._selection.clear(); } let objectToEdit: gdjs.RuntimeObject | null = null; if (objectUnderCursor) { const layer = this.getEditorLayer(objectUnderCursor.getLayer()); if ( layer && !layer._initialLayerData.isLocked && !this.isInstanceSealed(objectUnderCursor) ) { this._selection.toggle(objectUnderCursor); } if ( this._lastClickOnObjectUnderCursor.object === objectUnderCursor && Date.now() - this._lastClickOnObjectUnderCursor.time < timeBetweenClicksForDoubleClick ) { // Double click on the same object: edit the object. objectToEdit = objectUnderCursor; this._lastClickOnObjectUnderCursor = { object: null, time: 0, }; } else { this._lastClickOnObjectUnderCursor = { object: objectUnderCursor, time: Date.now(), }; } } this._sendSelectionUpdate({ objectToEdit, }); } } private _updateSelectionOutline({ objectUnderCursor, }: { objectUnderCursor: gdjs.RuntimeObject | null; }) { if (!this._currentScene) return; const selectedObjects = this._selection.getSelectedObjects(); // Add/update boxes for selected objects selectedObjects.forEach((object) => this._createBoundingBoxIfNeeded(object) ); if ( objectUnderCursor && !this._selectionBoxes.has(objectUnderCursor) && !this.isInstanceSealed(objectUnderCursor) ) { this._createBoundingBoxIfNeeded(objectUnderCursor); } // Remove boxes for deselected objects this._selectionBoxes.forEach((box, object) => { const isHovered = object === objectUnderCursor && !this._isTransformControlsHovered; const isInSelection = selectedObjects.includes(object); if (!isInSelection && !isHovered) { box.removeFromParent(); this._selectionBoxes.delete(object); } else { const isLocked = this.isInstanceLocked(object); const color = instanceWireframeColor[ (isLocked || !is3D(object) ? instanceStateFlag.locked : 0) | (isInSelection ? instanceStateFlag.selected : 0) | (isHovered ? instanceStateFlag.hovered : 0) ] || '#aaaaaa'; box.setColor(color); } }); this._selectionBoxes.forEach((box) => { box.update(); }); } private _createBoundingBoxIfNeeded(object: RuntimeObject): void { if (this._selectionBoxes.has(object)) { return; } const currentScene = this._currentScene; if (!currentScene) return; const objectLayer = this.getEditorLayer(object.getLayer()); if (!objectLayer) return; const threeGroup = objectLayer.getRenderer().getThreeGroup(); if (!threeGroup) return; const objectBoxHelper = new ObjectSelectionBoxHelper(object); threeGroup.add(objectBoxHelper.container); this._selectionBoxes.set(object, objectBoxHelper); } private _getTransformControlsMode(): 'translate' | 'rotate' | 'scale' { return this._transformControlsMode; } private _setTransformControlsMode( mode: 'translate' | 'rotate' | 'scale' ): void { this._transformControlsMode = mode; if (!this._selectionControls) { return; } const { threeTransformControls, dummyThreeObject } = this._selectionControls; threeTransformControls.mode = mode; const lastEditableSelectedObject = this._selection.getLastSelectedObject({ ignoreIf: (object) => this.isInstanceLocked(object) || this.isInstanceSealed(object), }); if (!lastEditableSelectedObject) { return; } const threeObject = lastEditableSelectedObject.get3DRendererObject(); if (!threeObject) { return; } dummyThreeObject.rotation.copy(threeObject.rotation); if (this._transformControlsMode === 'rotate') { dummyThreeObject.rotation.y = -dummyThreeObject.rotation.y; dummyThreeObject.rotation.z = -dummyThreeObject.rotation.z; } } private _forceUpdateSelectionControls() { if (this._selectionControls) { this._removeSelectionControls(); } this._updateSelectionControls(); } private _updateSelectionControls() { const inputManager = this._runtimeGame.getInputManager(); const currentScene = this._currentScene; if (!currentScene) return; const touchIds = getCurrentTouchIdentifiers(inputManager); const hasMultipleTouches = touchIds.length >= 2; // Selection controls are shown on the last object that can be manipulated // (and if none, selection controls are not shown). const lastEditableSelectedObject = this._selection.getLastSelectedObject({ ignoreIf: (object) => this.isInstanceLocked(object) || this.isInstanceSealed(object), }); // Space or multiple touches will hide the selection controls as they are // used to move the camera. const shouldHideSelectionControls = isSpacePressed(inputManager) || hasMultipleTouches; // Remove the selection controls if the last selected object has changed // or if nothing movable is selected. if ( this._selectionControls && (!lastEditableSelectedObject || (lastEditableSelectedObject && this._selectionControls.object !== lastEditableSelectedObject) || this._shouldDragSelectedObject() || shouldHideSelectionControls) ) { this._removeSelectionControls(); } // Create the selection controls on the last object that can be manipulated. if ( lastEditableSelectedObject && !this._selectionControls && !this._shouldDragSelectedObject() && !shouldHideSelectionControls && lastEditableSelectedObject.get3DRendererObject() ) { const cameraLayer = this.getCameraLayer( lastEditableSelectedObject.getLayer() ); if (cameraLayer) { const runtimeLayerRender = cameraLayer ? cameraLayer.getRenderer() : null; const threeCamera = runtimeLayerRender ? runtimeLayerRender.getThreeCamera() : null; const threeScene = runtimeLayerRender ? runtimeLayerRender.getThreeScene() : null; if (threeCamera && threeScene) { // Create and attach the transform controls. It is attached to a dummy object // to avoid the controls to directly move the runtime object (we handle this // manually). const threeTransformControls = new THREE_ADDONS.TransformControls( threeCamera, this._runtimeGame.getRenderer().getCanvas() || undefined ); patchAxesOnTransformControlsGizmos(threeTransformControls); patchColorsOnTransformControlsGizmos(threeTransformControls); patchNegativeAxisHandlesOnTransformControlsGizmos( threeTransformControls ); threeTransformControls.rotation.order = 'ZYX'; threeTransformControls.scale.y = -1; threeTransformControls.mode = this._transformControlsMode; threeTransformControls.traverse((obj) => { // To be detected correctly by OutlinePass. // @ts-ignore obj.isTransformControls = true; }); // The dummy object is an invisible object that is the one moved by the transform // controls. const dummyThreeObject = new THREE.Object3D(); this._updateDummyLocation( dummyThreeObject, lastEditableSelectedObject, threeTransformControls ); threeScene.add(dummyThreeObject); threeTransformControls.attach(dummyThreeObject); threeScene.add(threeTransformControls); // Keep track of the movement so the editor can apply it to the selection. let initialObjectX = 0; let initialObjectY = 0; let initialObjectZ = 0; const initialDummyPosition = new THREE.Vector3(); const initialDummyRotation = new THREE.Euler(); const initialDummyScale = new THREE.Vector3(); threeTransformControls.addEventListener('change', (e) => { if (!threeTransformControls.dragging) { this._selectionControlsMovementTotalDelta = null; this._updateDummyLocation( dummyThreeObject, lastEditableSelectedObject, threeTransformControls ); // Reset the initial position to the current position, so that // it's ready to be dragged again. initialObjectX = lastEditableSelectedObject.getX(); initialObjectY = lastEditableSelectedObject.getY(); initialObjectZ = is3D(lastEditableSelectedObject) ? lastEditableSelectedObject.getZ() : 0; initialDummyPosition.copy(dummyThreeObject.position); initialDummyRotation.copy(dummyThreeObject.rotation); initialDummyScale.copy(dummyThreeObject.scale); return; } let translationX = dummyThreeObject.position.x - initialDummyPosition.x; let translationY = dummyThreeObject.position.y - initialDummyPosition.y; let translationZ = dummyThreeObject.position.z - initialDummyPosition.z; if ( this._transformControlsMode === 'translate' && threeTransformControls.axis ) { if (threeTransformControls.axis === 'XYZ') { // We need to override the translation vector because // `threeTransformControls` don't know that the selection // must be excluded when looking for the cursor position. let isIntersectionFound = false; let intersectionX: float = 0; let intersectionY: float = 0; let intersectionZ: float = 0; if (is3D(lastEditableSelectedObject)) { const cursor = this._getCursorIn3D( this._selection.getSelectedObjects() ); if (cursor) { isIntersectionFound = true; [intersectionX, intersectionY, intersectionZ] = cursor; } } else { const projectedCursor = this._getProjectedCursor(); if (projectedCursor) { isIntersectionFound = true; [intersectionX, intersectionY] = projectedCursor; } } if (isIntersectionFound) { translationX = intersectionX - initialObjectX; translationY = intersectionY - initialObjectY; translationZ = intersectionZ - initialObjectZ; } else { translationX = 0; translationY = 0; translationZ = 0; } } const isMovingOnX = threeTransformControls.axis.includes('X'); const isMovingOnY = threeTransformControls.axis.includes('Y'); const isMovingOnZ = threeTransformControls.axis.includes('Z'); if (this._editorGrid.isSpanningEnabled(inputManager)) { if (isMovingOnX) { translationX = this._editorGrid.getSnappedX( initialObjectX + translationX ) - initialObjectX; } if (isMovingOnY) { translationY = this._editorGrid.getSnappedY( initialObjectY + translationY ) - initialObjectY; } if (isMovingOnZ) { translationZ = this._editorGrid.getSnappedZ( initialObjectZ + translationZ ) - initialObjectZ; } } } // 0.2 = 20% of the movement speed (Three.js transform controls scaling is too fast) const scaleDamping = threeTransformControls.axis && threeTransformControls.axis.length === 1 ? 1 : 0.2; this._selectionControlsMovementTotalDelta = { translationX, translationY, translationZ, rotationX: gdjs.toDegrees( dummyThreeObject.rotation.x - initialDummyRotation.x ), rotationY: -gdjs.toDegrees( dummyThreeObject.rotation.y - initialDummyRotation.y ), rotationZ: -gdjs.toDegrees( dummyThreeObject.rotation.z - initialDummyRotation.z ), scaleX: 1 + (dummyThreeObject.scale.x / initialDummyScale.x - 1) * scaleDamping, scaleY: 1 + (dummyThreeObject.scale.y / initialDummyScale.y - 1) * scaleDamping, scaleZ: 1 + (dummyThreeObject.scale.z / initialDummyScale.z - 1) * scaleDamping, }; this._hasSelectionActuallyMoved = this._hasSelectionActuallyMoved || !dummyThreeObject.position.equals(initialDummyPosition) || !dummyThreeObject.rotation.equals(initialDummyRotation) || !dummyThreeObject.scale.equals(initialDummyScale); }); this._selectionControls = { object: lastEditableSelectedObject, dummyThreeObject, threeTransformControls, }; } } } if ( lastEditableSelectedObject && this._selectionControls && !this._draggedNewObject && !this._draggedSelectedObject ) { const { threeTransformControls } = this._selectionControls; // Update the rotation snap. const inputManager = this._runtimeGame.getInputManager(); const shouldSnap = this._transformControlsMode === 'rotate' && isAltPressed(inputManager); const rotationSnap = shouldSnap ? gdjs.toRad(ROTATION_SNAP_DEGREES) : null; threeTransformControls.setRotationSnap(rotationSnap); // Update the grid. const axis = threeTransformControls.axis; if (axis) { const isMovingOnX = axis ? axis.includes('X') : false; const isMovingOnY = axis ? axis.includes('Y') : false; const isMovingOnZ = axis ? axis.includes('Z') : false; let gridNormal: 'X' | 'Y' | 'Z' = 'Z'; if (isMovingOnZ) { if (!isMovingOnX && !isMovingOnY) { // Choose the plan that faces the camera. const cameraRotation = Math.abs( gdjs.evtTools.common.angleDifference( this._editorCamera.getCameraRotation(), 0 ) ); if (cameraRotation <= 45 || cameraRotation > 135) { gridNormal = 'Y'; } else { gridNormal = 'X'; } } else if (!isMovingOnX) { gridNormal = 'X'; } else if (!isMovingOnY) { gridNormal = 'Y'; } } this._editorGrid.setNormal(gridNormal); } this._editorGrid.setPosition( lastEditableSelectedObject.getX(), lastEditableSelectedObject.getY(), is3D(lastEditableSelectedObject) ? lastEditableSelectedObject.getZ() : 0 ); const cameraLayer = this.getCameraLayer( lastEditableSelectedObject.getLayer() ); const threeScene = cameraLayer ? cameraLayer.getRenderer().getThreeScene() : null; if (threeScene) { this._editorGrid.setTreeScene(threeScene); } this._editorGrid.setVisible( this._transformControlsMode === 'translate' ); } } private _updateDummyLocation( dummyThreeObject: THREE.Object3D, lastEditableSelectedObject: gdjs.RuntimeObject, threeTransformControls: THREE_ADDONS.TransformControls ) { const threeObject = lastEditableSelectedObject.get3DRendererObject(); if (!threeObject) return; dummyThreeObject.position.copy(threeObject.position); dummyThreeObject.rotation.copy(threeObject.rotation); dummyThreeObject.scale.copy(threeObject.scale); if (this._transformControlsMode === 'rotate') { // This is only done for the rotate mode because it messes with the // orientation of the scale mode. dummyThreeObject.rotation.y = -dummyThreeObject.rotation.y; dummyThreeObject.rotation.z = -dummyThreeObject.rotation.z; dummyThreeObject.position.set( lastEditableSelectedObject.getCenterXInScene(), lastEditableSelectedObject.getCenterYInScene(), is3D(lastEditableSelectedObject) ? lastEditableSelectedObject.getCenterZInScene() : 0 ); } else { dummyThreeObject.position.set( lastEditableSelectedObject.getX(), lastEditableSelectedObject.getY(), is3D(lastEditableSelectedObject) ? lastEditableSelectedObject.getZ() : 0 ); } } private _removeSelectionControls(): void { if (!this._selectionControls) { return; } this._selectionControls.threeTransformControls.detach(); this._selectionControls.threeTransformControls.removeFromParent(); this._selectionControls.dummyThreeObject.removeFromParent(); this._editorGrid.setVisible(false); this._selectionControls = null; } activate(enable: boolean) { if (enable) { // Nothing to do. } else { this._runtimeGame.getSoundManager().unmuteEverything('in-game-editor'); this._removeSelectionControls(); // Cleanup selection boxes this._selectionBoxes.forEach((box) => { box.removeFromParent(); }); this._selectionBoxes.clear(); } } setVisibleStatus(visible: boolean) { this._isVisible = visible; } private _sendSelectionUpdate(options?: { hasSelectedObjectBeenModified?: boolean; isSendingBackSelectionForDefaultSize?: boolean; addedObjects?: Array; removedObjects?: Array; objectToEdit?: gdjs.RuntimeObject | null; }) { const debuggerClient = this._runtimeGame._debuggerClient; if (!debuggerClient) return; const getPersistentUuidsFromObjects = ( objects: Array ): Array => objects .map((object) => { if (!object.persistentUuid) return null; return { persistentUuid: object.persistentUuid }; }) .filter(isDefined); const getSelectedInstances = ( objects: Array ): Array => objects .map((object) => { if (!object.persistentUuid) { return null; } return { persistentUuid: object.persistentUuid, defaultWidth: object.getOriginalWidth(), defaultHeight: object.getOriginalHeight(), defaultDepth: is3D(object) ? object.getOriginalDepth() : undefined, }; }) .filter(isDefined); const updatedInstances = options && options.hasSelectedObjectBeenModified ? this._selection .getSelectedObjects() .map((object) => this.getInstanceDataFromRuntimeObject(object)) .filter(isDefined) : []; const addedInstances = options && options.addedObjects ? options.addedObjects .map((object) => this.getInstanceDataFromRuntimeObject(object)) .filter(isDefined) : []; const removedInstances = options && options.removedObjects ? options.removedObjects : []; this._removeInstances(removedInstances); this._updateInstances(updatedInstances); this._addInstances(addedInstances); debuggerClient.sendInstanceChanges({ isSendingBackSelectionForDefaultSize: options ? options.isSendingBackSelectionForDefaultSize || false : false, updatedInstances, addedInstances, selectedInstances: getSelectedInstances( this._selection.getSelectedObjects() ), removedInstances: getPersistentUuidsFromObjects(removedInstances), objectNameToEdit: options && options.objectToEdit ? options.objectToEdit.getName() : null, }); } private getInstanceDataFromRuntimeObject( runtimeObject: gdjs.RuntimeObject ): InstanceData | null { if (is3D(runtimeObject)) { if (!runtimeObject.persistentUuid) return null; const width = runtimeObject.getWidth(); const height = runtimeObject.getHeight(); const depth = runtimeObject.getDepth(); const defaultWidth = runtimeObject.getOriginalWidth(); const defaultHeight = runtimeObject.getOriginalHeight(); const defaultDepth = runtimeObject.getOriginalDepth(); const oldData = this._getInstanceData(runtimeObject.persistentUuid); const instanceData: InstanceData = { name: runtimeObject.getName(), zOrder: runtimeObject.getZOrder(), persistentUuid: runtimeObject.persistentUuid, x: runtimeObject.getX(), y: runtimeObject.getY(), z: runtimeObject.getZ(), layer: runtimeObject.getLayer(), angle: runtimeObject.getAngle(), rotationY: runtimeObject.getRotationY(), rotationX: runtimeObject.getRotationX(), customSize: width !== defaultWidth || height !== defaultHeight, width, height, depth: depth === defaultDepth ? undefined : depth, locked: oldData ? oldData.locked : false, sealed: oldData ? oldData.sealed : false, // TODO: how to transmit/should we transmit other properties? numberProperties: [], stringProperties: [], initialVariables: [], // @ts-ignore defaultWidth, defaultHeight, defaultDepth, }; return instanceData; } else { // TODO: handle 2D objects/instances. return null; } } private _removeInstances( removedInstances: Array<{ persistentUuid: string | null }> ) { for (const removedInstance of removedInstances) { // TODO: Might be worth indexing instances data const instanceIndex = this._editedInstanceDataList.findIndex( (instance) => instance.persistentUuid === removedInstance.persistentUuid ); if (instanceIndex >= 0) { this._editedInstanceDataList.splice(instanceIndex, 1); } } } private _updateInstances(updatedInstances: Array) { for (const updatedInstance of updatedInstances) { const oldInstance = this._getInstanceData( updatedInstance.persistentUuid ); if (oldInstance) { gdjs.HotReloader.assignOrDelete(oldInstance, updatedInstance, [ // These are never modified by the InGameEditor, so don't update them: 'initialVariables', 'numberProperties', 'stringProperties', ]); } } } private _getInstanceData( persistentUuid: string | null ): InstanceData | null { // TODO: Might be worth indexing instances data return persistentUuid ? this._editedInstanceDataList.find( (instanceData) => instanceData.persistentUuid === persistentUuid ) || null : null; } isInstanceLocked(object: gdjs.RuntimeObject): boolean { const instanceData = this._getInstanceData(object.persistentUuid); return !!instanceData && !!instanceData.locked; } isInstanceSealed(object: gdjs.RuntimeObject): boolean { const instanceData = this._getInstanceData(object.persistentUuid); return !!instanceData && !!instanceData.sealed; } private _addInstances(addedInstances: Array) { for (const addedInstance of addedInstances) { this._editedInstanceDataList.push(addedInstance); } } private _updateInnerAreaOutline(): void { if (!this._currentScene) return; const layer = this.getCameraLayer(''); if (!layer) { return; } const threeGroup = layer.getRenderer().getThreeGroup(); if (!threeGroup) { return; } if (!this._threeInnerArea) { const boxMesh = new THREE.Mesh( new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, alphaTest: 1, }) ); boxMesh.position.x = 0.5; boxMesh.position.y = 0.5; boxMesh.position.z = 0.5; const box = new THREE.BoxHelper(boxMesh, '#444444'); box.rotation.order = 'ZYX'; //box.material.depthTest = false; box.material.fog = false; const container = new THREE.Group(); container.rotation.order = 'ZYX'; container.add(box); threeGroup.add(container); this._threeInnerArea = container; } const threeInnerArea = this._threeInnerArea; if (this._innerArea) { const innerArea = this._innerArea; threeInnerArea.scale.x = innerArea.max[0] - innerArea.min[0]; threeInnerArea.scale.y = innerArea.max[1] - innerArea.min[1]; threeInnerArea.scale.z = innerArea.max[2] - innerArea.min[2]; threeInnerArea.position.x = innerArea.min[0]; threeInnerArea.position.y = innerArea.min[1]; threeInnerArea.position.z = innerArea.min[2]; } else { threeInnerArea.scale.x = this._runtimeGame.getOriginalWidth(); threeInnerArea.scale.y = this._runtimeGame.getOriginalHeight(); threeInnerArea.scale.z = 0.01; threeInnerArea.position.x = 0; threeInnerArea.position.y = 0; threeInnerArea.position.z = 0; } } private _handleContextMenu() { const inputManager = this._runtimeGame.getInputManager(); const isClick = Date.now() - this._pressedRightButtonTime <= pressAndReleaseForClickDuration; if (inputManager.isMouseButtonReleased(1) && isClick) { this._sendOpenContextMenu( inputManager.getCursorX(), inputManager.getCursorY() ); } } private _hasCursorStayedStillWhilePressed({ toleranceRadius, }: { toleranceRadius: float; }) { const inputManager = this._runtimeGame.getInputManager(); const deltaX = Math.abs( this._pressedOriginalCursorX - inputManager.getCursorX() ); const deltaY = Math.abs( this._pressedOriginalCursorY - inputManager.getCursorY() ); return ( deltaX * deltaX + deltaY * deltaY <= toleranceRadius * toleranceRadius ); } private _sendOpenContextMenu(cursorX: float, cursorY: float) { const debuggerClient = this._runtimeGame._debuggerClient; if (!debuggerClient) return; debuggerClient.sendOpenContextMenu(cursorX, cursorY); } private _handleShortcuts() { const inputManager = this._runtimeGame.getInputManager(); let alreadyHandledShortcut = false; if (isControlPressedOnly(inputManager)) { // Note: use `wasKeyJustPressed` instead of `wasKeyReleased` to avoid // macOS stealing the key release ("key up") information // when the "Meta" key is pressed. if (inputManager.wasKeyJustPressed(Z_KEY)) { this._sendUndo(); alreadyHandledShortcut = true; } else if (inputManager.wasKeyJustPressed(Y_KEY)) { this._sendRedo(); alreadyHandledShortcut = true; } else if (inputManager.wasKeyJustPressed(C_KEY)) { this._sendCopy(); alreadyHandledShortcut = true; } else if (inputManager.wasKeyJustPressed(V_KEY)) { this._sendPaste(); alreadyHandledShortcut = true; } else if (inputManager.wasKeyJustPressed(X_KEY)) { this._sendCut(); alreadyHandledShortcut = true; } } if (isControlPlusShiftPressedOnly(inputManager)) { if (inputManager.wasKeyJustPressed(Z_KEY)) { this._sendRedo(); alreadyHandledShortcut = true; } } // Send the shortcut to the editor (as the iframe does not bubble up // the event to the parent window). if (!alreadyHandledShortcut) { this._forwardShortcutsToEditor(inputManager); } } private _sendUndo() { const debuggerClient = this._runtimeGame._debuggerClient; if (!debuggerClient) return; debuggerClient.sendUndo(); } private _sendRedo() { const debuggerClient = this._runtimeGame._debuggerClient; if (!debuggerClient) return; debuggerClient.sendRedo(); } private _sendCopy() { const debuggerClient = this._runtimeGame._debuggerClient; if (!debuggerClient) return; debuggerClient.sendCopy(); } private _sendPaste() { const debuggerClient = this._runtimeGame._debuggerClient; if (!debuggerClient) return; debuggerClient.sendPaste(); } private _sendCut() { const debuggerClient = this._runtimeGame._debuggerClient; if (!debuggerClient) return; debuggerClient.sendCut(); } private _forwardShortcutsToEditor(inputManager: gdjs.InputManager) { const isCtrlPressed = inputManager.isKeyPressed(LEFT_CTRL_KEY) || inputManager.isKeyPressed(RIGHT_CTRL_KEY); const isMetaPressed = inputManager.isKeyPressed(LEFT_META_KEY) || inputManager.isKeyPressed(RIGHT_META_KEY); const isShiftKeyPressed = isShiftPressed(inputManager); const isAltKeyPressed = isAltPressed(inputManager); if ( !isCtrlPressed && !isMetaPressed && !isAltKeyPressed && !isShiftKeyPressed ) { return; } for (const locationAwareKeyCode of this._runtimeGame .getInputManager() .exceptionallyGetAllJustPressedKeys()) { const keyCode = exceptionallyGetKeyCodeFromLocationAwareKeyCode(locationAwareKeyCode); const debuggerClient = this._runtimeGame._debuggerClient; if (debuggerClient) { debuggerClient.sendKeyboardShortcut({ keyCode, metaKey: isMetaPressed, ctrlKey: isCtrlPressed, altKey: isAltKeyPressed, shiftKey: isShiftKeyPressed, }); } } } cancelDragNewInstance() { const editedInstanceContainer = this.getEditedInstanceContainer(); if (!editedInstanceContainer) return; if (this._draggedNewObject) { this._draggedNewObject.deleteFromScene(); this._draggedNewObject = null; } this._editorGrid.setVisible(false); } dragNewInstance({ name, dropped, isAltPressed, }: { name: string; dropped: boolean; isAltPressed: boolean; }) { const currentScene = this._currentScene; if (!currentScene) return; const editedInstanceContainer = this.getEditedInstanceContainer(); if (!editedInstanceContainer) return; const selectedLayer = this.getEditorLayer(this._selectedLayerName); if (!selectedLayer) return; const inputManager = this._runtimeGame.getInputManager(); if (this._draggedNewObject && this._draggedNewObject.getName() !== name) { this._draggedNewObject.deleteFromScene(); this._draggedNewObject = null; } if (!this._draggedNewObject) { const newObject = editedInstanceContainer.createObject(name); if (!newObject) return; if (!is3D(newObject)) { editedInstanceContainer.markObjectForDeletion(newObject); return; } newObject.persistentUuid = gdjs.makeUuid(); newObject.setLayer(selectedLayer.getName()); this._draggedNewObject = newObject; } // We don't update the object position when it's dropped because it makes // the object shift a bit. // It seems that newIDE doesn't send the last cursor position even when // the cursor stay still for several frames. The right position is only // sent it when the object is dropped which make the shift. // To reproduce the issue: // - remove the `if` // - drag the object vertically very fast // - stay still // - drop the object if (!dropped) { let isCursorFound = false; let cursorX = 0; let cursorY = 0; let cursorZ = 0; if (is3D(this._draggedNewObject)) { const cursor = this._getCursorIn3D([this._draggedNewObject]); if (cursor) { [cursorX, cursorY, cursorZ] = cursor; isCursorFound = true; } } else { const projectedCursor = this._getProjectedCursor(); if (projectedCursor) { [cursorX, cursorY] = projectedCursor; isCursorFound = true; } } if (isCursorFound) { this._editorGrid.setNormal('Z'); this._editorGrid.setPosition(cursorX, cursorY, cursorZ); const cameraLayer = this.getCameraLayer( this._draggedNewObject.getLayer() ); const threeScene = cameraLayer ? cameraLayer.getRenderer().getThreeScene() : null; if (threeScene) { this._editorGrid.setTreeScene(threeScene); } this._editorGrid.setVisible(true); if (this._editorGrid.isSpanningEnabled(inputManager, isAltPressed)) { cursorX = this._editorGrid.getSnappedX(cursorX); cursorY = this._editorGrid.getSnappedY(cursorY); } // TODO The object Z should be changed according to the new X and Y // to match the ground. this._draggedNewObject.setX(Math.round(cursorX)); this._draggedNewObject.setY(Math.round(cursorY)); // We don't round on Z because if cubes are stacked and there depth // is not round it would leave an interstice between them. if (is3D(this._draggedNewObject)) { this._draggedNewObject.setZ(cursorZ); } } } if (dropped) { if (this._draggedNewObject) { const isLayer3D = selectedLayer.getRenderer().getThreeGroup(); if (isLayer3D) { const cameraX = selectedLayer.getCameraX(); const cameraY = selectedLayer.getCameraY(); const cameraZ = getCameraZ( currentScene, selectedLayer.getName(), 0 ); const closestIntersect = this._getClosestIntersectionUnderCursor([ this._draggedNewObject, ]); if (closestIntersect && !is3D(this._draggedNewObject)) { // Avoid to create a 2D object hidden under a 3D one. this.cancelDragNewInstance(); return; } let cursorX: float; let cursorY: float; let cursorZ: float; if (closestIntersect) { cursorX = closestIntersect.point.x; cursorY = -closestIntersect.point.y; cursorZ = closestIntersect.point.z; } else { const projectedCursor = this._getProjectedCursor(); if (!projectedCursor) { // Avoid to create an object behind the camera when it's dropped over the horizon. this.cancelDragNewInstance(); return; } cursorX = projectedCursor[0]; cursorY = projectedCursor[1]; cursorZ = 0; } const cursorDistance = Math.hypot( cursorX - cameraX, cursorY - cameraY, cursorZ - cameraZ ); if ( cursorDistance > selectedLayer.getInitialCamera3DFarPlaneDistance() ) { // Avoid to create an object outside of the rendered area. this.cancelDragNewInstance(); return; } } this._sendSelectionUpdate({ addedObjects: [this._draggedNewObject], }); } this._draggedNewObject = null; return; } } /** * @returns The cursor projected on the plane Z = 0 or `null` if the cursor is in the sky. */ _getProjectedCursor(): FloatPoint | null { const currentScene = this._currentScene; if (!currentScene) return null; const layer = this.getCameraLayer(''); if (!layer) { return null; } const cameraX = layer.getCameraX(); const cameraY = layer.getCameraY(); const cameraZ = getCameraZ(currentScene, layer.getName(), 0); const cursorX = gdjs.evtTools.input.getCursorX( currentScene, layer.getName(), 0 ); const cursorY = gdjs.evtTools.input.getCursorY( currentScene, layer.getName(), 0 ); const deltaX = cursorX - cameraX; const deltaY = cursorY - cameraY; const deltaZ = 0 - cameraZ; const threeCamera = layer.getRenderer().getThreeCamera(); if (!threeCamera) { return [cursorX, cursorY]; } const { forward } = getCameraForwardVector(threeCamera); // It happens when the cursor is over the horizon and projected on the plane Z = 0. const isCursorBehindTheCamera = forward.dot(new THREE.Vector3(deltaX, deltaY, deltaZ)) < 1; if (isCursorBehindTheCamera) { return null; } return [cursorX, cursorY]; } reloadInstances(instances: Array) { const editedInstanceContainer = this.getEditedInstanceContainer(); if (!editedInstanceContainer) return; // TODO: Might be worth indexing instances data and runtime objects by their // persistentUuid (See HotReloader.indexByPersistentUuid). editedInstanceContainer .getAdhocListOfAllInstances() .forEach((runtimeObject) => { const instance = instances.find( (instance) => instance.persistentUuid === runtimeObject.persistentUuid ); if (instance) { runtimeObject.setX(instance.x); runtimeObject.setY(instance.y); if (instance.customSize) { runtimeObject.setWidth(instance.width); runtimeObject.setHeight(instance.height); } else { runtimeObject.setWidth(runtimeObject.getOriginalWidth()); runtimeObject.setHeight(runtimeObject.getOriginalHeight()); } runtimeObject.setAngle(instance.angle); runtimeObject.setLayer(instance.layer); if (is3D(runtimeObject)) { runtimeObject.setZ(instance.z === undefined ? 0 : instance.z); runtimeObject.setRotationX( instance.rotationX == undefined ? 0 : instance.rotationX ); runtimeObject.setRotationY( instance.rotationY == undefined ? 0 : instance.rotationY ); runtimeObject.setDepth( instance.depth == undefined ? runtimeObject.getOriginalDepth() : instance.depth ); } runtimeObject.extraInitializationFromInitialInstance(instance); } }); this._updateInstances(instances); this._forceUpdateSelectionControls(); } addInstances(instances: Array) { const editedInstanceContainer = this.getEditedInstanceContainer(); if (!editedInstanceContainer) return; editedInstanceContainer.createObjectsFrom(instances, 0, 0, 0, true); this._addInstances(instances); } deleteSelection() { const editedInstanceContainer = this.getEditedInstanceContainer(); if (!editedInstanceContainer) return; this._removeInstances(this._selection.getSelectedObjects()); for (const object of this._selection.getSelectedObjects()) { object.deleteFromScene(); } this._selection.clear(); } private _getClosestIntersectionUnderCursor( excludedObjects?: Array ): THREE.Intersection | null { const runtimeGame = this._runtimeGame; const firstIntersectsByLayer: { [layerName: string]: null | { intersect: THREE.Intersection; }; } = {}; const cursorX = runtimeGame.getInputManager().getCursorX(); const cursorY = runtimeGame.getInputManager().getCursorY(); const layerNames = []; const currentScene = this._currentScene; const threeRenderer = runtimeGame.getRenderer().getThreeRenderer(); if (!currentScene || !threeRenderer) return null; // Only check layer 0, on which Three.js objects are by default, // and move selection boxes + dragged object to layer 1 so they // are not considered by raycasting. this._raycaster.layers.set(0); this._selectionBoxes.forEach((box) => box.setLayer(1)); if (this._threeInnerArea) { for (const child of this._threeInnerArea.children) { child.layers.set(1); } } if (excludedObjects) { for (const excludedObject of excludedObjects) { if (is3D(excludedObject)) { const draggedRendererObject = excludedObject.get3DRendererObject(); if (draggedRendererObject) { draggedRendererObject.layers.set(1); draggedRendererObject.traverse((object) => object.layers.set(1)); } } } } currentScene.getAllLayerNames(layerNames); layerNames.forEach((layerName) => { const runtimeLayerRender = currentScene .getLayer(layerName) .getRenderer(); const threeCamera = runtimeLayerRender.getThreeCamera(); const threeGroup = runtimeLayerRender.getThreeGroup(); if (!threeCamera || !threeGroup) return; // Note that raycasting is done by Three.js, which means it could slow down // if lots of 3D objects are shown. We consider that if this needs improvements, // this must be handled by the game engine culling const normalizedDeviceCoordinates = this._getTempVector2d( this._getNormalizedScreenX(cursorX), this._getNormalizedScreenY(cursorY) ); this._raycaster.setFromCamera(normalizedDeviceCoordinates, threeCamera); const intersects = this._raycaster.intersectObjects( threeGroup.children, true ); const firstIntersect = intersects[0]; if (!firstIntersect) return; firstIntersectsByLayer[layerName] = { intersect: firstIntersect, }; }); // Reset selection boxes layers so they are properly displayed. this._selectionBoxes.forEach((box) => box.setLayer(0)); if (this._threeInnerArea) { for (const child of this._threeInnerArea.children) { child.layers.set(0); } } // Also reset the layer of the object being added. if (excludedObjects) { for (const excludedObject of excludedObjects) { if (is3D(excludedObject)) { const draggedRendererObject = excludedObject.get3DRendererObject(); if (draggedRendererObject) { draggedRendererObject.layers.set(0); draggedRendererObject.traverse((object) => object.layers.set(0)); } } } } let closestIntersect: THREE.Intersection | null = null; for (const intersect of Object.values(firstIntersectsByLayer)) { if ( intersect && (!closestIntersect || intersect.intersect.distance < closestIntersect.distance) ) { closestIntersect = intersect.intersect; } } return closestIntersect; } private _getCursorIn3D( excludedObjects?: Array ): Point3D | null { const closestIntersect = this._getClosestIntersectionUnderCursor(excludedObjects); if (closestIntersect) { return [ closestIntersect.point.x, -closestIntersect.point.y, closestIntersect.point.z, ]; } const projectedCursor = this._getProjectedCursor(); if (!projectedCursor) { return null; } return [projectedCursor[0], projectedCursor[1], 0]; } private _getNormalizedScreenX(x: float): float { return (x / this._runtimeGame.getGameResolutionWidth()) * 2 - 1; } private _getNormalizedScreenY(y: float): float { return -(y / this._runtimeGame.getGameResolutionHeight()) * 2 + 1; } getObjectUnderCursor(): gdjs.RuntimeObject | null { const closestIntersect = this._getClosestIntersectionUnderCursor(); if (!closestIntersect) { const editedInstanceContainer = this.getEditedInstanceContainer(); if (!editedInstanceContainer) { return null; } const cursor = this._getCursorIn3D(); if (!cursor || cursor[2] !== 0) { return null; } let topObject2D: gdjs.RuntimeObject | null = null; let topLayer: gdjs.RuntimeLayer | null = null; let topLayerIndex = 0; for (const object of editedInstanceContainer.getAdhocListOfAllInstances()) { if (is3D(object) || !object.cursorOnObject()) { continue; } const layer = editedInstanceContainer.getLayer(object.getLayer()); const layerIndex = editedInstanceContainer._orderedLayers.indexOf(layer); if ( !topObject2D || layerIndex > topLayerIndex || (layer === topLayer && object.getZOrder() > topObject2D.getZOrder()) ) { topObject2D = object; topLayer = layer; topLayerIndex = layerIndex; } } return topObject2D; } return this._getObject3D(closestIntersect.object); } private _getObject3D( initialThreeObject: THREE.Object3D ): gdjs.RuntimeObject | null { const editedInstanceContainer = this.getEditedInstanceContainer(); if (!editedInstanceContainer) return null; // Walk back up the object hierarchy to find the runtime object. // We sadly need to do that because the intersection can be found on a Mesh or other // child Three.js object, instead of the one exposed by the gdjs.RuntimeObject. let threeObject: THREE.Object3D | null = initialThreeObject; while (threeObject) { const runtimeObject: gdjs.RuntimeObject | null = // @ts-ignore threeObject.gdjsRuntimeObject; if (runtimeObject) { let rootRuntimeObject = runtimeObject; while ( rootRuntimeObject.getInstanceContainer() instanceof gdjs.CustomRuntimeObjectInstanceContainer && rootRuntimeObject.getInstanceContainer() !== editedInstanceContainer ) { rootRuntimeObject = ( rootRuntimeObject.getInstanceContainer() as gdjs.CustomRuntimeObjectInstanceContainer ).getOwner(); } return rootRuntimeObject; } threeObject = threeObject.parent || null; } return null; } getCameraState(): EditorCameraState { return this._getEditorCamera().getCameraState(); } restoreCameraState(editorCamera3D: EditorCameraState) { this._getEditorCamera().restoreCameraState(editorCamera3D); } private _updateMouseCursor() { const mouseCursor = this._getEditorCamera().getRequestedMouseCursor(); const canvas = this._runtimeGame.getRenderer().getCanvas(); if (canvas) { canvas.style.cursor = mouseCursor || 'default'; } } private _handleTransformControlsMode() { const inputManager = this._runtimeGame.getInputManager(); if (inputManager.wasKeyJustPressed(KEY_DIGIT_1)) { this._setTransformControlsMode('translate'); } else if (inputManager.wasKeyJustPressed(KEY_DIGIT_2)) { this._setTransformControlsMode('rotate'); } else if (inputManager.wasKeyJustPressed(KEY_DIGIT_3)) { this._setTransformControlsMode('scale'); } } private _handlePointerLock() { const inputManager = this._runtimeGame.getInputManager(); const renderer = this._runtimeGame.getRenderer(); const isRightButtonPressed = inputManager.isMouseButtonPressed(1); const isMiddleButtonPressed = inputManager.isMouseButtonPressed(2); // Request pointer lock when right or middle button is pressed, // but only after the delay to ensure it's not a click. if ( (isRightButtonPressed && Date.now() - this._pressedRightButtonTime > pressAndReleaseForClickDuration) || (isMiddleButtonPressed && Date.now() - this._pressedMiddleButtonTime > pressAndReleaseForClickDuration) ) { renderer.requestPointerLock('in-game-editor'); } // Exit pointer lock as soon as we can. if (!isRightButtonPressed && !isMiddleButtonPressed) { if (renderer.isPointerLocked()) { renderer.exitPointerLock('in-game-editor'); } } } updateTargetFramerate(elapsedTime: float) { const inputManager = this._runtimeGame.getInputManager(); if ( inputManager.anyKeyPressed() || inputManager.anyMouseButtonPressed() || inputManager.getAllTouchIdentifiers().length > 0 || inputManager.getMouseWheelDelta() !== 0 || inputManager.getMouseWheelDeltaX() !== 0 || inputManager.getMouseWheelDeltaZ() !== 0 ) { this._timeSinceLastInteraction = 0; } if (this._draggedNewObject) { this._timeSinceLastInteraction = 0; } this._timeSinceLastInteraction += elapsedTime; // Adapt the framerate to avoid consuming too much CPU when the editor is not visible // or not interacted with. if (!this._isVisible) { this._runtimeGame.setMaximumFps(0.3); } else { if (this._timeSinceLastInteraction > 1000) { this._runtimeGame.setMaximumFps(10); } else { this._runtimeGame.setMaximumFps(60); } } } updateAndRender() { if (!this._unregisterContextLostListener) { this._setupWebGLContextLostListener(); } const objectUnderCursor: gdjs.RuntimeObject | null = this.getObjectUnderCursor(); this._runtimeGame.getSoundManager().muteEverything('in-game-editor'); const inputManager = this._runtimeGame.getInputManager(); // Ensure we don't keep keys considered as pressed if the editor is blurred. if (!hasWindowFocus && this._windowHadFocus) { inputManager.releaseAllPressedKeys(); } this._windowHadFocus = hasWindowFocus; // Update the state of the mouse/cursor for this frame. const mouseLeftButtonJustPressed = !this._wasMouseLeftButtonPressed && inputManager.isMouseButtonPressed(0); const mouseRightButtonJustPressed = !this._wasMouseRightButtonPressed && inputManager.isMouseButtonPressed(1); const mouseMiddleButtonJustPressed = !this._wasMouseMiddleButtonPressed && inputManager.isMouseButtonPressed(2); if (mouseLeftButtonJustPressed || mouseRightButtonJustPressed) { this._pressedOriginalCursorX = inputManager.getCursorX(); this._pressedOriginalCursorY = inputManager.getCursorY(); } if (mouseRightButtonJustPressed) { this._pressedRightButtonTime = Date.now(); } if (mouseMiddleButtonJustPressed) { this._pressedMiddleButtonTime = Date.now(); } // Note: don't add more logic here. Instead, create a new method // to handle what you need, possibly with a dedicated class to abstract it. if (!this._selectionControls) { this._isTransformControlsHovered = false; } else if ( this._previousCursorX !== inputManager.getMouseX() || this._previousCursorY !== inputManager.getMouseY() ) { this._isTransformControlsHovered = !!this._selectionControls.threeTransformControls.axis; } this._handlePointerLock(); this._handleTransformControlsMode(); this._handleCameraMovement(); this._handleSelectedObjectDragging(); this._handleSelectionMovement(); this._updateSelectionBox(); this._handleSelection({ objectUnderCursor }); this._updateSelectionOutline({ objectUnderCursor }); // Custom objects only update their position at the end of the frame // because they don't override position setters like built-in objects do. // Since the instance position is not yet set when `onCreated` is called, // they will be at (0; 0; 0) during the 1st step. // When they are selected and `switchToSceneOrVariant` has just been // called, it avoid to put the control at (0; 0; 0). if (!this._isFirstFrame) { this._updateSelectionControls(); } this._updateInnerAreaOutline(); this._handleContextMenu(); this._handleShortcuts(); this._updateMouseCursor(); const domElementContainer = this._runtimeGame .getRenderer() .getDomElementContainer(); if (domElementContainer) { this._toolbar.render(domElementContainer); } // Prepare state of the mouse/cursor for the next frame. this._wasMovingSelectionLastFrame = !!this._selectionControlsMovementTotalDelta; if (!this._selectionControlsMovementTotalDelta) { this._hasSelectionActuallyMoved = false; } this._wasMouseLeftButtonPressed = inputManager.isMouseButtonPressed(0); this._wasMouseRightButtonPressed = inputManager.isMouseButtonPressed(1); this._wasMouseMiddleButtonPressed = inputManager.isMouseButtonPressed(2); this._previousCursorX = inputManager.getMouseX(); this._previousCursorY = inputManager.getMouseY(); if (this._currentScene) { this._currentScene._updateObjectsForInGameEditor(); this._currentScene.render(); } this._isFirstFrame = false; } private _getEditorCamera(): EditorCamera { return this._editorCamera; } } class Toolbar { private _renderedElements: { container: HTMLDivElement; moveButton: HTMLButtonElement; rotateButton: HTMLButtonElement; scaleButton: HTMLButtonElement; freeCameraButton: HTMLButtonElement; orbitCameraButton: HTMLButtonElement; } | null = null; private _parent: HTMLElement | null = null; private _getTransformControlsMode: () => 'translate' | 'rotate' | 'scale'; private _setTransformControlsMode: ( mode: 'translate' | 'rotate' | 'scale' ) => void; private _focusOnSelection: () => void; private _switchToFreeCamera: () => void; private _switchToOrbitCamera: () => void; private _isFreeCamera: () => boolean; private _getSvgIconUrl: (iconName: string) => string; private _hasSelectionControlsShown: () => boolean; private addOrUpdateToolbarStyle() { const id = 'InGameEditor-Toolbar-Style'; let styleElement = document.getElementById(id); if (!styleElement) { styleElement = document.createElement('style'); styleElement.id = 'InGameEditor-Toolbar-Style'; document.head.appendChild(styleElement); } styleElement.textContent = ` .InGameEditor-Toolbar-Centering-Container { position: fixed; left: 0; right: 0; display: flex; flex-direction: row; align-items: center; justify-content: center; transition: transform 0.1s ease-in-out; } .InGameEditor-Toolbar-Container { position: relative; pointer-events: all; display: flex; flex-direction: row; align-items: center; justify-content: center; top: 2px; border-radius: 3px; padding: 4px; gap: 6px; } .InGameEditor-Toolbar-Container-Background { position: absolute; inset: 0; background-color: var(--in-game-editor-theme-toolbar-background-color); opacity: 0.5; border-radius: 3px; z-index: -1; } .InGameEditor-Toolbar-Button { width: 24px; height: 24px; border-radius: 4px; border: none; padding: 0; border-width: 0; display: flex; align-items: center; justify-content: center; background-color: transparent; } .InGameEditor-Toolbar-Button-Icon { width: 20px; height: 20px; display:inline-block; background-color: var(--in-game-editor-theme-text-color-primary); } .InGameEditor-Toolbar-Button:hover:not(.InGameEditor-Toolbar-Button-Active):not(.InGameEditor-Toolbar-Button-Disabled) { background-color: rgba(255, 255, 255, 0.08); } .InGameEditor-Toolbar-Button-Active { background-color: var(--in-game-editor-theme-icon-button-selected-background-color); } .InGameEditor-Toolbar-Button-Active .InGameEditor-Toolbar-Button-Icon { background-color: var(--in-game-editor-theme-icon-button-selected-color); } .InGameEditor-Toolbar-Divider { width: 1px; height: 24px; background-color: var(--in-game-editor-theme-toolbar-separator-color); } `; } constructor({ getTransformControlsMode, setTransformControlsMode, focusOnSelection, switchToFreeCamera, switchToOrbitCamera, isFreeCamera, getSvgIconUrl, hasSelectionControlsShown, }: { getTransformControlsMode: () => 'translate' | 'rotate' | 'scale'; setTransformControlsMode: ( mode: 'translate' | 'rotate' | 'scale' ) => void; focusOnSelection: () => void; switchToFreeCamera: () => void; switchToOrbitCamera: () => void; isFreeCamera: () => boolean; getSvgIconUrl: (iconName: string) => string; hasSelectionControlsShown: () => boolean; }) { this._getTransformControlsMode = getTransformControlsMode; this._setTransformControlsMode = setTransformControlsMode; this._focusOnSelection = focusOnSelection; this._switchToFreeCamera = switchToFreeCamera; this._switchToOrbitCamera = switchToOrbitCamera; this._isFreeCamera = isFreeCamera; this._getSvgIconUrl = getSvgIconUrl; this._hasSelectionControlsShown = hasSelectionControlsShown; this.addOrUpdateToolbarStyle(); } render(parent: HTMLElement) { if (this._renderedElements && this._parent !== parent) { this._renderedElements.container.remove(); this._renderedElements = null; } this._parent = parent; const makeIcon = ({ svgIconUrl }: { svgIconUrl: string }) => ( ); if (!this._renderedElements) { const container = (
); this._parent.appendChild(container); this._renderedElements = { container, moveButton: container.querySelector('#move-button')!, rotateButton: container.querySelector('#rotate-button')!, scaleButton: container.querySelector('#scale-button')!, freeCameraButton: container.querySelector('#free-camera-button')!, orbitCameraButton: container.querySelector('#orbit-camera-button')!, }; } const displayed = this._hasSelectionControlsShown(); this._renderedElements.container.tabIndex = displayed ? 0 : -1; this._renderedElements.container.style.transform = displayed ? 'translateY(0)' : 'translateY(-50px)'; const transformControlsMode = this._getTransformControlsMode(); this._renderedElements.freeCameraButton.classList.toggle( 'InGameEditor-Toolbar-Button-Active', this._isFreeCamera() ); this._renderedElements.orbitCameraButton.classList.toggle( 'InGameEditor-Toolbar-Button-Active', !this._isFreeCamera() ); this._renderedElements.moveButton.classList.toggle( 'InGameEditor-Toolbar-Button-Active', transformControlsMode === 'translate' ); this._renderedElements.rotateButton.classList.toggle( 'InGameEditor-Toolbar-Button-Active', transformControlsMode === 'rotate' ); this._renderedElements.scaleButton.classList.toggle( 'InGameEditor-Toolbar-Button-Active', transformControlsMode === 'scale' ); } } class EditorGrid { editor: gdjs.InGameEditor; gridHelper: THREE.GridHelper; isVisible = true; normal: 'Z' | 'Y' | 'X' = 'Z'; position = new THREE.Vector3(); isForcefullyHidden = true; gridWidth: float = 0; gridHeight: float = 0; gridDepth: float = 0; gridOffsetX: float = 0; gridOffsetY: float = 0; gridOffsetZ: float = 0; gridColor: integer = 0; gridAlpha: float = 1; isSnappingEnabledByDefault = false; threeScene: THREE.Scene | null = null; constructor(editor: gdjs.InGameEditor) { this.editor = editor; this.gridHelper = new THREE.GridHelper(); } setSettings(instancesEditorSettings: InstancesEditorSettings): void { this.isForcefullyHidden = !instancesEditorSettings.grid; this.gridWidth = instancesEditorSettings.gridWidth || 0; this.gridHeight = instancesEditorSettings.gridHeight || 0; this.gridDepth = instancesEditorSettings.gridDepth === undefined ? 32 : instancesEditorSettings.gridDepth; this.gridOffsetX = instancesEditorSettings.gridOffsetX || 0; this.gridOffsetY = instancesEditorSettings.gridOffsetY || 0; this.gridOffsetZ = instancesEditorSettings.gridOffsetZ || 0; this.gridColor = instancesEditorSettings.gridColor; this.gridAlpha = instancesEditorSettings.gridAlpha; this.isSnappingEnabledByDefault = instancesEditorSettings.snap; this.rebuildGrid(); } private rebuildGrid(): void { this.gridHelper.removeFromParent(); this.gridHelper.dispose(); this.gridHelper = new THREE.GridHelper( 10, 10, this.gridColor, this.gridColor ); this.gridHelper.material.transparent = true; this.gridHelper.material.opacity = this.gridAlpha; this.gridHelper.rotation.order = 'ZYX'; this.updateVisibility(); this.updateLocation(); if (this.threeScene) { this.threeScene.add(this.gridHelper); } } private updateLocation() { const { gridWidth, gridHeight, gridDepth } = this; const { x, y, z } = this.position; if (this.normal === 'X') { this.gridHelper.rotation.set(0, 0, Math.PI / 2); this.gridHelper.scale.set(gridWidth, 1, gridDepth || 0); this.gridHelper.position.set( x, this.getSnappedY(y), this.getSnappedZ(z) ); } else if (this.normal === 'Y') { this.gridHelper.rotation.set(0, 0, 0); this.gridHelper.scale.set(gridHeight, 1, gridDepth || 0); this.gridHelper.position.set( this.getSnappedX(x), y, this.getSnappedZ(z) ); } else { this.gridHelper.rotation.set(Math.PI / 2, 0, 0); this.gridHelper.scale.set(gridWidth, 1, gridHeight); this.gridHelper.position.set( this.getSnappedX(x), this.getSnappedY(y), z ); } } setTreeScene(threeScene: THREE.Scene): void { this.threeScene = threeScene; this.gridHelper.removeFromParent(); threeScene.add(this.gridHelper); } setVisible(isVisible: boolean): void { this.isVisible = isVisible; this.updateVisibility(); } private updateVisibility(): void { this.gridHelper.visible = this.isVisible && !this.isForcefullyHidden; } setNormal(normal: 'X' | 'Y' | 'Z') { this.normal = normal; if (this.normal === 'X') { this.gridHelper.rotation.set(0, 0, Math.PI / 2); } else if (this.normal === 'Y') { this.gridHelper.rotation.set(0, 0, 0); } else { this.gridHelper.rotation.set(Math.PI / 2, 0, 0); } this.updateLocation(); } setPosition(x: float, y: float, z: float): void { this.position.set(x, y, z); this.updateLocation(); } getSnappedX(x: float): float { const { gridWidth, gridOffsetX } = this; return snap(x, gridWidth, gridOffsetX); } getSnappedY(y: float): float { const { gridHeight, gridOffsetY } = this; return snap(y, gridHeight, gridOffsetY); } getSnappedZ(z: float): float { const { gridDepth, gridOffsetY } = this; return snap(z, gridDepth || 0, gridOffsetY); } isSpanningEnabled( inputManager: gdjs.InputManager, considerAltPressed?: boolean ): boolean { const altPressed = considerAltPressed === undefined ? isAltPressed(inputManager) : considerAltPressed; return this.isSnappingEnabledByDefault !== altPressed; } } /** @category In-Game Editor */ export type EditorCameraState = { cameraMode: 'free' | 'orbit'; positionX: float; positionY: float; positionZ: float; rotationAngle: float; elevationAngle: float; distance: float; }; class EditorCamera implements CameraControl { editor: gdjs.InGameEditor; orbitCameraControl: OrbitCameraControl; freeCameraControl: FreeCameraControl; private _hasChanged = false; private _hadChanged = false; private _mouseCursor: string | null = null; constructor(editor: gdjs.InGameEditor) { this.editor = editor; this.orbitCameraControl = new OrbitCameraControl(this); this.freeCameraControl = new FreeCameraControl(this); this.orbitCameraControl.setEnabled(false); } isFreeCamera(): boolean { return this.freeCameraControl.isEnabled(); } private getActiveCamera() { return this.isFreeCamera() ? this.freeCameraControl : this.orbitCameraControl; } getRequestedMouseCursor(): string | null { return this._mouseCursor; } switchToOrbitAroundObject(object: gdjs.RuntimeObject): void { this.switchToOrbitAroundPosition( object.getCenterXInScene(), object.getCenterYInScene(), is3D(object) ? object.getUnrotatedAABBMinZ() : 0 ); this.onHasCameraChanged(); } switchToOrbitAroundPosition( targetX: float, targetY: float, targetZ: float ): void { this.orbitCameraControl.target.x = targetX; this.orbitCameraControl.target.y = targetY; this.orbitCameraControl.target.z = targetZ; if (this.freeCameraControl.isEnabled()) { this.orbitCameraControl.rotationAngle = this.freeCameraControl.rotationAngle; this.orbitCameraControl.elevationAngle = this.freeCameraControl.elevationAngle; } this.orbitCameraControl.setEnabled(true); this.freeCameraControl.setEnabled(false); this.onHasCameraChanged(); } switchToOrbitAroundZ0(maxDistance: number): void { if (this.freeCameraControl.isEnabled()) { // Match orientation and orbit from the current free camera position. this.orbitCameraControl.rotationAngle = this.freeCameraControl.rotationAngle; this.orbitCameraControl.elevationAngle = this.freeCameraControl.elevationAngle; this.orbitCameraControl.orbitFromPositionAroundZ0( this.freeCameraControl.position.x, this.freeCameraControl.position.y, this.freeCameraControl.position.z, maxDistance ); } this.orbitCameraControl.setEnabled(true); this.freeCameraControl.setEnabled(false); this.onHasCameraChanged(); } switchToFreeCamera(): void { this.orbitCameraControl.setEnabled(false); this.freeCameraControl.setEnabled(true); this.freeCameraControl.position.x = this.orbitCameraControl.getCameraX(); this.freeCameraControl.position.y = this.orbitCameraControl.getCameraY(); this.freeCameraControl.position.z = this.orbitCameraControl.getCameraZ(); this.freeCameraControl.rotationAngle = this.orbitCameraControl.rotationAngle; this.freeCameraControl.elevationAngle = this.orbitCameraControl.elevationAngle; this.onHasCameraChanged(); } resetRotationToTopDown(): void { this.orbitCameraControl.resetRotationToTopDown(); this.freeCameraControl.resetRotationToTopDown(); this.onHasCameraChanged(); } setOrbitDistance(distance: number): void { this.orbitCameraControl.distance = distance; this.onHasCameraChanged(); } step(): void { const runtimeGame = this.editor.getRuntimeGame(); const inputManager = runtimeGame.getInputManager(); const touchIds = getCurrentTouchIdentifiers(inputManager); const touchCount = touchIds.length; // Always allow to use Space+click to switch to free camera and pan. // Display a grab cursor to indicate that. this._mouseCursor = null; if (isSpacePressed(inputManager)) { this._mouseCursor = 'grab'; // Switch to pan if space + left click is used. if (inputManager.isMouseButtonPressed(0) && !this.isFreeCamera()) { this.switchToFreeCamera(); } } // Also switch to pan if shift + wheel click is used. if ( isShiftPressed(inputManager) && inputManager.isMouseButtonPressed(2) && !this.isFreeCamera() ) { this.switchToFreeCamera(); } // Shift to orbit if just mouse wheel click is used if ( !isShiftPressed(inputManager) && inputManager.isMouseButtonPressed(2) && this.isFreeCamera() ) { const maxDistance = 4000; // Large enough to orbit quickly on most parts of a level. this.switchToOrbitAroundZ0(maxDistance); } // With touches, 2 touches will always pan/zoom the camera with the free camera. if (touchCount === 2 && !this.isFreeCamera()) { this.switchToFreeCamera(); } // With touches, 3 touches will orbit around the point "in front of the camera". if ( (touchCount === 3 || inputManager.isKeyPressed(O_KEY)) && this.isFreeCamera() ) { const maxDistance = 4000; // Large enough to orbit quickly on most parts of a level. this.switchToOrbitAroundZ0(maxDistance); } this.orbitCameraControl.step(); this.freeCameraControl.step(); if (this._hadChanged && !this._hasChanged) { this._sendCameraState(); } this._hadChanged = this._hasChanged; this._hasChanged = false; } onHasCameraChanged() { this._hasChanged = true; } updateCamera(currentScene: RuntimeScene, layer: RuntimeLayer): void { this.getActiveCamera().updateCamera(currentScene, layer); } zoomBy(zoomInFactor: float): void { this.getActiveCamera().zoomBy(zoomInFactor); this.onHasCameraChanged(); } setZoom(zoom: float): void { const distance = this._getCameraZFromZoom(zoom); this.switchToOrbitAroundPosition(this.getAnchorX(), this.getAnchorY(), 0); this.resetRotationToTopDown(); this.setOrbitDistance(distance); this.onHasCameraChanged(); } getAnchorX(): float { return this.getActiveCamera().getAnchorX(); } getAnchorY(): float { return this.getActiveCamera().getAnchorY(); } getAnchorZ(): float { return this.getActiveCamera().getAnchorZ(); } zoomToInitialPosition(visibleScreenArea: { minX: number; minY: number; maxX: number; maxY: number; }) { const runtimeGame = this.editor.getRuntimeGame(); this.zoomToFitArea( { minX: 0, minY: 0, minZ: 0, maxX: runtimeGame.getOriginalWidth(), maxY: runtimeGame.getOriginalHeight(), maxZ: 0, }, visibleScreenArea, 0.1 ); } zoomToFitObjects( objects: Array, visibleScreenArea: { minX: number; minY: number; maxX: number; maxY: number; }, margin: float ) { if (objects.length === 0) { const runtimeGame = this.editor.getRuntimeGame(); this.zoomToFitArea( { minX: 0, minY: 0, minZ: 0, maxX: runtimeGame.getOriginalWidth(), maxY: runtimeGame.getOriginalHeight(), maxZ: 0, }, visibleScreenArea, 0.1 ); } let minX = Number.MAX_VALUE; let minY = Number.MAX_VALUE; let minZ = Number.MAX_VALUE; let maxX = Number.MIN_VALUE; let maxY = Number.MIN_VALUE; let maxZ = Number.MIN_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); } this.zoomToFitArea( { minX, minY, minZ, maxX, maxY, maxZ, }, visibleScreenArea, margin ); } zoomToFitArea( sceneArea: { minX: number; minY: number; minZ: number; maxX: number; maxY: number; maxZ: number; }, visibleScreenArea: { minX: number; minY: number; maxX: number; maxY: number; }, margin: float ) { const sceneAreaWidth = sceneArea.maxX - sceneArea.minX; const sceneAreaHeight = sceneArea.maxY - sceneArea.minY; const runtimeGame = this.editor.getRuntimeGame(); const renderedWidth = runtimeGame.getGameResolutionWidth(); const renderedHeight = runtimeGame.getGameResolutionHeight(); const editorWidth = (visibleScreenArea.maxX - visibleScreenArea.minX) * renderedWidth; const editorHeight = (visibleScreenArea.maxY - visibleScreenArea.minY) * renderedHeight; const isContentWider = editorWidth * sceneAreaHeight < sceneAreaWidth * editorHeight; const zoom = (1 - 2 * margin) * (isContentWider ? editorWidth / sceneAreaWidth : editorHeight / sceneAreaHeight); const distance = this._getCameraZFromZoom(zoom); const sceneAreaCenterX = (sceneArea.maxX + sceneArea.minX) / 2; const sceneAreaCenterY = (sceneArea.maxY + sceneArea.minY) / 2; this.switchToOrbitAroundPosition( sceneAreaCenterX, sceneAreaCenterY, sceneArea.minZ ); this.resetRotationToTopDown(); this.setOrbitDistance(distance); this.onHasCameraChanged(); } /** * Get the camera center Z position. * * @param zoom The camera zoom. * @return The z position of the camera */ _getCameraZFromZoom = (zoom: float): float => { const runtimeGame = this.editor.getRuntimeGame(); // Set the camera so that it displays the whole PixiJS plane, as if it was a 2D rendering. // The Z position is computed by taking the half height of the displayed rendering, // and using the angle of the triangle defined by the field of view to compute the length // of the triangle defining the distance between the camera and the rendering plane. return ( (0.5 * runtimeGame.getGameResolutionHeight()) / zoom / Math.tan(0.5 * gdjs.toRad(editorCameraFov)) ); }; getCameraRotation(): float { return this.getActiveCamera().rotationAngle; } getCameraState(): EditorCameraState { return this.getActiveCamera()._getCameraState(); } restoreCameraState(cameraState: EditorCameraState) { if (cameraState.cameraMode === 'free') { this.orbitCameraControl.setEnabled(false); this.freeCameraControl.setEnabled(true); this.freeCameraControl._restoreCameraState(cameraState); } else { this.freeCameraControl.setEnabled(false); this.orbitCameraControl.setEnabled(true); this.orbitCameraControl._restoreCameraState(cameraState); } this.onHasCameraChanged(); } private _sendCameraState() { const runtimeGame = this.editor.getRuntimeGame(); const debuggerClient = runtimeGame._debuggerClient; if (!debuggerClient) return; debuggerClient.sendCameraState(this.getCameraState()); } } interface CameraControl { step(): void; updateCamera(currentScene: RuntimeScene, layer: RuntimeLayer): void; zoomBy(zoomInFactor: float): void; resetRotationToTopDown(): void; getAnchorX(): float; getAnchorY(): float; getAnchorZ(): float; } class OrbitCameraControl implements CameraControl { private _editorCamera: EditorCamera; target: THREE.Vector3 = new THREE.Vector3(); rotationAngle: float = 0; elevationAngle: float = 90; distance: float = 800; private _isEnabled: boolean = true; private _lastCursorX: float = 0; private _lastCursorY: float = 0; private _wasMouseRightButtonPressed = false; private _wasMouseMiddleButtonPressed = false; private _gestureActiveTouchIds: Array = []; private _gestureLastCentroidX: float = 0; private _gestureLastCentroidY: float = 0; constructor(editorCamera: EditorCamera) { this._editorCamera = editorCamera; } isEnabled(): boolean { return this._isEnabled; } setEnabled(isEnabled: boolean): void { this._isEnabled = isEnabled; this._editorCamera.onHasCameraChanged(); } step(): void { const runtimeGame = this._editorCamera.editor.getRuntimeGame(); const inputManager = runtimeGame.getInputManager(); const renderer = runtimeGame.getRenderer(); const isRightButtonPressed = inputManager.isMouseButtonPressed(1); const isMiddleButtonPressed = inputManager.isMouseButtonPressed(2); if (this._isEnabled) { // Right click: rotate the camera. // Middle click: also rotate the camera. if ( (isRightButtonPressed && // The camera should not move the 1st frame this._wasMouseRightButtonPressed) || (isMiddleButtonPressed && // The camera should not move the 1st frame this._wasMouseMiddleButtonPressed) ) { // Use movement deltas when pointer is locked, otherwise use cursor position delta const xDelta = renderer.isPointerLocked() ? inputManager.getMouseMovementX() : inputManager.getCursorX() - this._lastCursorX; const yDelta = renderer.isPointerLocked() ? inputManager.getMouseMovementY() : inputManager.getCursorY() - this._lastCursorY; const rotationSpeed = 0.2; this.rotationAngle += xDelta * rotationSpeed; this.elevationAngle += yDelta * rotationSpeed; this._editorCamera.onHasCameraChanged(); } // Mouse wheel: movement on the plane or forward/backward movement. const wheelDeltaY = inputManager.getMouseWheelDelta(); if (wheelDeltaY !== 0 && shouldZoom(inputManager)) { this.distance = Math.max( 10, this.distance * Math.pow(2, -wheelDeltaY / 512) ); this._editorCamera.onHasCameraChanged(); } // Movement with keyboard: zoom in/out. if (isControlOrCmdPressed(inputManager)) { if (inputManager.wasKeyJustPressed(EQUAL_KEY)) { this.zoomBy(zoomInFactor); } else if (inputManager.wasKeyJustPressed(MINUS_KEY)) { this.zoomBy(zoomOutFactor); } } // Touch gestures const touchIds = getCurrentTouchIdentifiers(inputManager); const touchCount = touchIds.length; if (touchCount === 0) { this._gestureActiveTouchIds = []; } else if (!areSameTouchesSet(this._gestureActiveTouchIds, touchIds)) { // Start or reinitialize gesture tracking this._gestureActiveTouchIds = touchIds.slice(); if (touchCount === 3) { const centroid3 = getTouchesCentroid(inputManager); this._gestureLastCentroidX = centroid3.x; this._gestureLastCentroidY = centroid3.y; } } else { // Process ongoing gesture if (touchCount === 3) { // Three-finger rotation: // - adjust elevation angle from vertical movement of centroid // - adjust rotation angle from horizontal movement of centroid const centroid3 = getTouchesCentroid(inputManager); const dx3 = centroid3.x - this._gestureLastCentroidX; const dy3 = centroid3.y - this._gestureLastCentroidY; if (dx3 !== 0) { const tiltSpeed = 0.2; this.rotationAngle += dx3 * tiltSpeed; this._editorCamera.onHasCameraChanged(); } if (dy3 !== 0) { const tiltSpeed = 0.2; this.elevationAngle += dy3 * tiltSpeed; if (this.elevationAngle < -45) this.elevationAngle = -45; if (this.elevationAngle > 175) this.elevationAngle = 175; this._editorCamera.onHasCameraChanged(); } this._gestureLastCentroidX = centroid3.x; this._gestureLastCentroidY = centroid3.y; } } } else { // Reset gesture tracking when camera control is disabled. this._gestureActiveTouchIds = []; } this._wasMouseRightButtonPressed = isRightButtonPressed; this._wasMouseMiddleButtonPressed = isMiddleButtonPressed; this._lastCursorX = inputManager.getCursorX(); this._lastCursorY = inputManager.getCursorY(); } getAnchorX(): float { return this.target.x; } getAnchorY(): float { return this.target.y; } getAnchorZ(): float { return this.target.z; } private _getCameraForwardVector(): Point3D { // Camera forward (from camera toward where it looks), unit length. const cosYaw = Math.cos(gdjs.toRad(this.rotationAngle + 90)); const sinYaw = Math.sin(gdjs.toRad(this.rotationAngle + 90)); const cosEl = Math.cos(gdjs.toRad(this.elevationAngle)); const sinEl = Math.sin(gdjs.toRad(this.elevationAngle)); const fwdX = -cosYaw * cosEl; const fwdY = -sinYaw * cosEl; const fwdZ = -sinEl; return [fwdX, fwdY, fwdZ]; } getCameraX(): float { const [fwdX, ,] = this._getCameraForwardVector(); return this.target.x - this.distance * fwdX; } getCameraY(): float { const [, fwdY] = this._getCameraForwardVector(); return this.target.y - this.distance * fwdY; } getCameraZ(): float { const [, , fwdZ] = this._getCameraForwardVector(); return this.target.z - this.distance * fwdZ; } orbitFromPositionAroundZ0( x: float, y: float, z: float, targetMaxDistance: float ): void { const [fwdX, fwdY, fwdZ] = this._getCameraForwardVector(); // Intersect ray P(t) = camera position + t * forward with plane z = 0: // z + t*fwdZ = 0 => t = -z / fwdZ let tPlane: number | null = null; if (Math.abs(fwdZ) > 1e-6) { const t = -z / fwdZ; // Only keep intersections "in front" of the camera if (t > 0) tPlane = t; } // Choose distance along the ray: // - If there is a valid intersection within targetMaxDistance, use it // - Otherwise, clamp to targetMaxDistance const distance = tPlane !== null && tPlane <= targetMaxDistance ? tPlane : targetMaxDistance; // Target point = point ahead of camera along forward by distance this.target.x = x + fwdX * distance; this.target.y = y + fwdY * distance; this.target.z = z + fwdZ * distance; // Distance so that orbit camera stays exactly at the specified position this.distance = distance; } updateCamera(currentScene: RuntimeScene, layer: RuntimeLayer): void { const layerName = layer.getName(); layer.setCameraX(this.getCameraX()); layer.setCameraY(this.getCameraY()); setCameraZ(currentScene, this.getCameraZ(), layerName, 0); setCameraRotationX(currentScene, 90 - this.elevationAngle, layerName, 0); setCameraRotationY(currentScene, 0, layerName, 0); layer.setCameraRotation(this.rotationAngle); } zoomBy(zoomFactor: float): void { // The distance is proportional to the inverse of the zoom. this.distance /= zoomFactor; this._editorCamera.onHasCameraChanged(); } resetRotationToTopDown(): void { this.rotationAngle = 0; this.elevationAngle = 90; this._editorCamera.onHasCameraChanged(); } _getCameraState(): EditorCameraState { return { cameraMode: 'orbit', positionX: this.target.x, positionY: this.target.y, positionZ: this.target.z, rotationAngle: this.rotationAngle, elevationAngle: this.elevationAngle, distance: this.distance, }; } _restoreCameraState(cameraState: EditorCameraState): void { if (cameraState.cameraMode !== 'orbit') { return; } this.target.x = cameraState.positionX; this.target.y = cameraState.positionY; this.target.z = cameraState.positionZ; this.rotationAngle = cameraState.rotationAngle; this.elevationAngle = cameraState.elevationAngle; this.distance = cameraState.distance; this._editorCamera.onHasCameraChanged(); } } class FreeCameraControl implements CameraControl { private _editorCamera: EditorCamera; position: THREE.Vector3 = new THREE.Vector3(); rotationAngle: float = 0; elevationAngle: float = 30; private _isEnabled: boolean = true; private _euler: THREE.Euler = new THREE.Euler(0, 0, 0, 'ZYX'); private _rotationMatrix: THREE.Matrix4 = new THREE.Matrix4(); private _lastCursorX: float = 0; private _lastCursorY: float = 0; private _wasMouseRightButtonPressed = false; // Touch gesture state private _gestureActiveTouchIds: Array = []; private _gestureLastCentroidX: float = 0; private _gestureLastCentroidY: float = 0; private _gestureLastDistance: float = 0; constructor(editorCamera: EditorCamera) { this._editorCamera = editorCamera; } isEnabled(): boolean { return this._isEnabled; } setEnabled(isEnabled: boolean): void { this._isEnabled = isEnabled; this._editorCamera.onHasCameraChanged(); } step(): void { const runtimeGame = this._editorCamera.editor.getRuntimeGame(); const inputManager = runtimeGame.getInputManager(); const renderer = runtimeGame.getRenderer(); const isRightButtonPressed = inputManager.isMouseButtonPressed(1); if (this._isEnabled) { const { right, up, forward } = this.getCameraVectors(); const moveCameraByVector = (vector: THREE.Vector3, scale: number) => { this.position.x += vector.x * scale; this.position.y += vector.y * scale; this.position.z += vector.z * scale; this._editorCamera.onHasCameraChanged(); }; // Mouse wheel: movement on the plane or forward/backward movement. const wheelDeltaY = inputManager.getMouseWheelDelta(); const wheelDeltaX = inputManager.getMouseWheelDeltaX(); if (wheelDeltaY !== 0 && shouldZoom(inputManager)) { moveCameraByVector(forward, wheelDeltaY); } else if ( wheelDeltaY !== 0 && shouldScrollHorizontally(inputManager) ) { moveCameraByVector(right, wheelDeltaY / 5); } else if (wheelDeltaX !== 0 || wheelDeltaY !== 0) { moveCameraByVector(up, wheelDeltaY / 5); moveCameraByVector(right, wheelDeltaX / 5); } // Touch gestures const touchIds = getCurrentTouchIdentifiers(inputManager); const touchCount = touchIds.length; if (touchCount === 0) { this._gestureActiveTouchIds = []; } else if (!areSameTouchesSet(this._gestureActiveTouchIds, touchIds)) { // Start or reinitialize gesture tracking this._gestureActiveTouchIds = touchIds.slice(); if (touchCount === 2) { const centroid = getTouchesCentroid(inputManager); this._gestureLastCentroidX = centroid.x; this._gestureLastCentroidY = centroid.y; this._gestureLastDistance = getTouchesDistance(inputManager); } } else { // Process ongoing gesture if (touchCount === 2) { // Pan: move on the camera plane by centroid delta const centroid = getTouchesCentroid(inputManager); const dx = (centroid.x - this._gestureLastCentroidX) * 5; const dy = (centroid.y - this._gestureLastCentroidY) * 5; if (dx !== 0 || dy !== 0) { moveCameraByVector(up, dy); moveCameraByVector(right, -dx); this._gestureLastCentroidX = centroid.x; this._gestureLastCentroidY = centroid.y; } // Pinch: zoom forward/backward based on distance delta const dist = getTouchesDistance(inputManager); const pinchDelta = (dist - this._gestureLastDistance) * 10; if (pinchDelta !== 0) { moveCameraByVector(forward, pinchDelta); this._gestureLastDistance = dist; } } } // Movement with the keyboard: // Either arrow keys (move in the camera plane) or WASD ("FPS move" + Q/E for up/down). const moveSpeed = isShiftPressed(inputManager) ? 48 : 6; if ( !isControlOrCmdPressed(inputManager) && !isAltPressed(inputManager) ) { if (inputManager.isKeyPressed(LEFT_KEY)) { moveCameraByVector(right, -moveSpeed); } if (inputManager.isKeyPressed(RIGHT_KEY)) { moveCameraByVector(right, moveSpeed); } if (inputManager.isKeyPressed(UP_KEY)) { moveCameraByVector(up, moveSpeed); } if (inputManager.isKeyPressed(DOWN_KEY)) { moveCameraByVector(up, -moveSpeed); } // Forward/back if (inputManager.isKeyPressed(W_KEY)) { moveCameraByVector(forward, moveSpeed); } if (inputManager.isKeyPressed(S_KEY)) { moveCameraByVector(forward, -moveSpeed); } // Left/right (strafe) if (inputManager.isKeyPressed(A_KEY)) { moveCameraByVector(right, -moveSpeed); } if (inputManager.isKeyPressed(D_KEY)) { moveCameraByVector(right, moveSpeed); } // Up/down if (inputManager.isKeyPressed(Q_KEY)) { moveCameraByVector(up, -moveSpeed); } if (inputManager.isKeyPressed(E_KEY)) { moveCameraByVector(up, moveSpeed); } } // Movement with keyboard: zoom in/out. if (isControlOrCmdPressed(inputManager)) { if (inputManager.wasKeyJustPressed(EQUAL_KEY)) { this.zoomBy(zoomInFactor); } else if (inputManager.wasKeyJustPressed(MINUS_KEY)) { this.zoomBy(zoomOutFactor); } } // Space + click: move the camera on its plane. // Shift + Wheel click: same. if ( (isSpacePressed(inputManager) && inputManager.isMouseButtonPressed(0)) || (isShiftPressed(inputManager) && inputManager.isMouseButtonPressed(2)) ) { // Use movement deltas when pointer is locked, otherwise use cursor position delta const xDelta = renderer.isPointerLocked() ? inputManager.getMouseMovementX() : inputManager.getCursorX() - this._lastCursorX; const yDelta = renderer.isPointerLocked() ? inputManager.getMouseMovementY() : inputManager.getCursorY() - this._lastCursorY; moveCameraByVector(up, yDelta); moveCameraByVector(right, -xDelta); } // Right click: rotate the camera. if ( isRightButtonPressed && // The camera should not move the 1st frame this._wasMouseRightButtonPressed ) { // Use movement deltas when pointer is locked, otherwise use cursor position delta const xDelta = renderer.isPointerLocked() ? inputManager.getMouseMovementX() : inputManager.getCursorX() - this._lastCursorX; const yDelta = renderer.isPointerLocked() ? inputManager.getMouseMovementY() : inputManager.getCursorY() - this._lastCursorY; const rotationSpeed = 0.2; this.rotationAngle += xDelta * rotationSpeed; this.elevationAngle += yDelta * rotationSpeed; this._editorCamera.onHasCameraChanged(); } } else { // Reset gesture tracking when camera control is disabled. this._gestureActiveTouchIds = []; } this._wasMouseRightButtonPressed = isRightButtonPressed; this._lastCursorX = inputManager.getCursorX(); this._lastCursorY = inputManager.getCursorY(); } moveForward(distanceDelta: number) { const { forward } = this.getCameraVectors(); const moveCameraByVector = (vector: THREE.Vector3, scale: number) => { this.position.x += vector.x * scale; this.position.y += vector.y * scale; this.position.z += vector.z * scale; this._editorCamera.onHasCameraChanged(); }; moveCameraByVector(forward, distanceDelta); } updateCamera(currentScene: RuntimeScene, layer: RuntimeLayer): void { const layerName = layer.getName(); layer.setCameraX(this.position.x); layer.setCameraY(this.position.y); setCameraZ(currentScene, this.position.z, layerName, 0); setCameraRotationX(currentScene, 90 - this.elevationAngle, layerName, 0); setCameraRotationY(currentScene, 0, layerName, 0); layer.setCameraRotation(this.rotationAngle); } private getCameraVectors() { this._euler.x = gdjs.toRad(90 - this.elevationAngle); this._euler.z = gdjs.toRad(this.rotationAngle); this._rotationMatrix.makeRotationFromEuler(this._euler); // threeCamera.matrixWorld is a 4x4. In Three.js, the columns correspond to: // [ right.x, up.x, forwardNeg.x, pos.x // right.y, up.y, forwardNeg.y, pos.y // right.z, up.z, forwardNeg.z, pos.z // 0, 0, 0, 1 ] // // By default, a Three.js camera looks down the -Z axis, so the "forward" axis // in the matrix is actually the negative Z column. We'll call it "forward" below. const elements = this._rotationMatrix.elements; // Local right axis in world space: const right = new THREE.Vector3(elements[0], elements[1], elements[2]); // Local forward axis in world space (note we take the negative of that column). const forward = new THREE.Vector3( elements[8], elements[9], -elements[10] ); // Local up axis in world space: orthogonal to both right and forward. const up = new THREE.Vector3().crossVectors(forward, right); // Normalize them, just in case (they should generally be unit vectors). right.normalize(); up.normalize(); forward.normalize(); return { right, up, forward }; } getAnchorX(): float { return this.position.x; } getAnchorY(): float { return this.position.y; } getAnchorZ(): float { return this.position.z; } zoomBy(zoomInFactor: float): void { this.moveForward(zoomInFactor > 1 ? 200 : -200); this._editorCamera.onHasCameraChanged(); } resetRotationToTopDown(): void { this.rotationAngle = 0; this.elevationAngle = 90; this._editorCamera.onHasCameraChanged(); } _getCameraState(): EditorCameraState { return { cameraMode: 'free', positionX: this.position.x, positionY: this.position.y, positionZ: this.position.z, rotationAngle: this.rotationAngle, elevationAngle: this.elevationAngle, distance: 0, }; } _restoreCameraState(cameraState: EditorCameraState): void { if (cameraState.cameraMode !== 'free') { return; } this.position.x = cameraState.positionX; this.position.y = cameraState.positionY; this.position.z = cameraState.positionZ; this.rotationAngle = cameraState.rotationAngle; this.elevationAngle = cameraState.elevationAngle; this._editorCamera.onHasCameraChanged(); } } const getCameraZ = ( runtimeScene: RuntimeScene, layerName: string, cameraIndex: integer ): float => { return gdjs.scene3d.camera ? gdjs.scene3d.camera.getCameraZ(runtimeScene, layerName, cameraIndex) : 0; }; const setCameraZ = ( runtimeScene: RuntimeScene, z: float, layerName: string, cameraIndex: integer ) => { if (gdjs.scene3d.camera) { gdjs.scene3d.camera.setCameraZ(runtimeScene, z, layerName, cameraIndex); } }; const setCameraRotationX = ( runtimeScene: RuntimeScene, angle: float, layerName: string, cameraIndex: integer ) => { if (gdjs.scene3d.camera) { gdjs.scene3d.camera.setCameraRotationX( runtimeScene, angle, layerName, cameraIndex ); } }; const setCameraRotationY = ( runtimeScene: RuntimeScene, angle: float, layerName: string, cameraIndex: integer ) => { if (gdjs.scene3d.camera) { gdjs.scene3d.camera.setCameraRotationY( runtimeScene, angle, layerName, cameraIndex ); } }; class ObjectSelectionBoxHelper { object: gdjs.RuntimeObject; dummyObject3DForObject2D: THREE.Object3D | null = null; boxHelper: THREE.BoxHelper; container: THREE.Group; constructor(object: gdjs.RuntimeObject) { this.object = object; let threeObject = object.get3DRendererObject(); if (!threeObject) { threeObject = new THREE.Group(); threeObject.add( new THREE.Mesh( new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial() ) ); this.dummyObject3DForObject2D = threeObject; } // Use a group to invert the Y-axis as the GDevelop Y axis is inverted // compared to Three.js. This is somehow necessary because the position // of the BoxHelper is always (0, 0, 0) and the geometry is hard to manipulate. this.container = new THREE.Group(); this.container.rotation.order = 'ZYX'; this.container.scale.y = -1; this.boxHelper = new THREE.BoxHelper(threeObject, '#f2a63c'); this.boxHelper.rotation.order = 'ZYX'; this.boxHelper.material.depthTest = false; this.boxHelper.material.fog = false; this.container.add(this.boxHelper); } update() { if (this.dummyObject3DForObject2D) { this.dummyObject3DForObject2D.position.set( this.object.getCenterXInScene(), -this.object.getCenterYInScene(), 0 ); this.dummyObject3DForObject2D.scale.set( this.object.getWidth() + 2, this.object.getHeight() + 2, 0 ); } this.boxHelper.update(); } removeFromParent() { this.container.removeFromParent(); } setLayer(layer: number): void { this.boxHelper.layers.set(layer); } setColor(color: THREE.ColorRepresentation) { this.boxHelper.material.color.set(color); this.boxHelper.material.needsUpdate = true; } } /** * A 3D object placeholder that is used as a fall back when the object type * is not known. */ class UnknownRuntimeObject extends gdjs.RuntimeObject3D { _renderer: UnknownRuntimeObjectRenderer; constructor( instanceContainer: gdjs.RuntimeInstanceContainer, objectData: gdjs.Object3DData, instanceData?: InstanceData ) { super(instanceContainer, objectData, instanceData); this._renderer = new UnknownRuntimeObjectRenderer( this, instanceContainer ); } override getRenderer(): gdjs.RuntimeObject3DRenderer { return this._renderer; } override onDestroyed(): void { super.onDestroyed(); this._renderer.onDestroyed(); } } gdjs.registerObject('', UnknownRuntimeObject); class UnknownRuntimeObjectRenderer extends gdjs.RuntimeObject3DRenderer { private _threeObject: THREE.Mesh; constructor( runtimeObject: UnknownRuntimeObject, instanceContainer: gdjs.RuntimeInstanceContainer ) { const cube = new THREE.Mesh( new THREE.BoxGeometry(), runtimeObject .getInstanceContainer() .getGame() .getImageManager() .getThreeMaterial('', { useTransparentTexture: false, forceBasicMaterial: true, vertexColors: false, }) ); super(runtimeObject, instanceContainer, cube); this._threeObject = cube; this.updateSize(); this.updatePosition(); this.updateRotation(); } onDestroyed(): void { this._threeObject.removeFromParent(); this._threeObject.geometry.dispose(); } } }