diff --git a/Extensions/DebuggerTools/JsExtension.js b/Extensions/DebuggerTools/JsExtension.js index c7e702b485b4..08b2aa00ee68 100644 --- a/Extensions/DebuggerTools/JsExtension.js +++ b/Extensions/DebuggerTools/JsExtension.js @@ -85,6 +85,53 @@ module.exports = { .setIncludeFile('Extensions/DebuggerTools/debuggertools.js') .setFunctionName('gdjs.evtTools.debuggerTools.enableDebugDraw'); + extension + .addAction( + 'EnableDebugDraw3D', + _('Draw 3D collision shapes'), + _( + 'This activates the display of wireframe meshes showing the 3D collision shapes of objects using the 3D physics engine (box, sphere, capsule, cylinder).' + ), + _( + 'Enable 3D debug draw of collision shapes: _PARAM1_ (color: _PARAM2_, depth test: _PARAM3_)' + ), + '', + 'res/actions/planicon24.png', + 'res/actions/planicon.png' + ) + .addCodeOnlyParameter('currentScene', '') + .addParameter('yesorno', _('Enable 3D debug draw'), '', true) + .setDefaultValue('yes') + .addParameter('color', _('Wireframe color'), '', true) + .setDefaultValue('"0;255;0"') + .addParameter( + 'yesorno', + _('Apply depth test (hide shapes behind geometry)'), + '', + true + ) + .setDefaultValue('yes') + .getCodeExtraInformation() + .setIncludeFile('Extensions/DebuggerTools/debuggertools.js') + .setFunctionName('gdjs.evtTools.debuggerTools.enableDebugDraw3D'); + + extension + .addAction( + 'ToggleDebugDraw3D', + _('Toggle 3D collision shapes drawing'), + _( + 'Toggles the display of wireframe meshes showing the 3D collision shapes of objects using the 3D physics engine. The last used color and depth test settings are reused.' + ), + _('Toggle 3D debug draw of collision shapes'), + '', + 'res/actions/planicon24.png', + 'res/actions/planicon.png' + ) + .addCodeOnlyParameter('currentScene', '') + .getCodeExtraInformation() + .setIncludeFile('Extensions/DebuggerTools/debuggertools.js') + .setFunctionName('gdjs.evtTools.debuggerTools.toggleDebugDraw3D'); + extension .addAction( 'ConsoleLog', diff --git a/Extensions/DebuggerTools/debuggertools.ts b/Extensions/DebuggerTools/debuggertools.ts index e212d97b2992..6f520b8916f9 100644 --- a/Extensions/DebuggerTools/debuggertools.ts +++ b/Extensions/DebuggerTools/debuggertools.ts @@ -51,6 +51,43 @@ namespace gdjs { showCustomPoints ); }; + + /** + * Enable or disable the 3D debug draw of collision shapes. + * @param instanceContainer - The current container. + * @param enableDebugDraw - true to enable the 3D debug draw, false to disable it. + * @param color - Wireframe color string (format "R;G;B"). + * @param depthTest - true to enable depth testing on the wireframe. + */ + export const enableDebugDraw3D = function ( + instanceContainer: gdjs.RuntimeInstanceContainer, + enableDebugDraw: boolean, + color: string, + depthTest: boolean + ) { + const rgb = (color || '0;255;0').split(';'); + const colorHex = gdjs.rgbToHexNumber( + parseInt(rgb[0], 10) || 0, + parseInt(rgb[1], 10) || 0, + parseInt(rgb[2], 10) || 0 + ); + instanceContainer.enableDebugDraw3D(enableDebugDraw, colorHex, depthTest); + }; + + /** + * Toggle the 3D debug draw of collision shapes, reusing the last color + * and depth test settings (defaulting to green / depth test on). + * @param instanceContainer - The current container. + */ + export const toggleDebugDraw3D = function ( + instanceContainer: gdjs.RuntimeInstanceContainer + ) { + instanceContainer.enableDebugDraw3D( + !instanceContainer._debugDraw3DEnabled, + instanceContainer._debugDraw3DColorHex, + instanceContainer._debugDraw3DDepthTest + ); + }; } } } diff --git a/Extensions/Physics3DBehavior/Physics3DRuntimeBehavior.ts b/Extensions/Physics3DBehavior/Physics3DRuntimeBehavior.ts index 9bdc14d9f285..bcd570fb7dc6 100644 --- a/Extensions/Physics3DBehavior/Physics3DRuntimeBehavior.ts +++ b/Extensions/Physics3DBehavior/Physics3DRuntimeBehavior.ts @@ -409,31 +409,7 @@ namespace gdjs { this.collisionChecker = new gdjs.Physics3DRuntimeBehavior.DefaultCollisionChecker(this); this.owner3D = owner; - this.bodyType = behaviorData.bodyType; - this.bullet = behaviorData.bullet; - this.fixedRotation = behaviorData.fixedRotation; - this._shape = behaviorData.shape; - this.meshShapeResourceName = behaviorData.meshShapeResourceName || ''; - this.shapeOrientation = - behaviorData.shape === 'Box' ? 'Z' : behaviorData.shapeOrientation; - this.shapeDimensionA = behaviorData.shapeDimensionA; - this.shapeDimensionB = behaviorData.shapeDimensionB; - this.shapeDimensionC = behaviorData.shapeDimensionC; - this.shapeOffsetX = behaviorData.shapeOffsetX || 0; - this.shapeOffsetY = behaviorData.shapeOffsetY || 0; - this.shapeOffsetZ = behaviorData.shapeOffsetZ || 0; - this.massCenterOffsetX = behaviorData.massCenterOffsetX || 0; - this.massCenterOffsetY = behaviorData.massCenterOffsetY || 0; - this.massCenterOffsetZ = behaviorData.massCenterOffsetZ || 0; - this.density = Math.max(0.0001, behaviorData.density); - this.massOverride = behaviorData.massOverride || 0; - this.friction = behaviorData.friction; - this.restitution = behaviorData.restitution; - this.linearDamping = Math.max(0, behaviorData.linearDamping); - this.angularDamping = Math.max(0, behaviorData.angularDamping); - this.gravityScale = behaviorData.gravityScale; - this.layers = behaviorData.layers; - this.masks = behaviorData.masks; + this._applyBehaviorData(behaviorData); this._sharedData = Physics3DSharedData.getSharedData( instanceContainer.getScene(), behaviorData.name @@ -1131,6 +1107,78 @@ namespace gdjs { this._sharedData.stepped = false; } + private _applyBehaviorData(data: any): void { + if ('bodyType' in data) this.bodyType = data.bodyType; + if ('bullet' in data) this.bullet = data.bullet; + if ('fixedRotation' in data) this.fixedRotation = data.fixedRotation; + + if ('shape' in data) { + this._shape = data.shape; + const orientation = + 'shapeOrientation' in data + ? data.shapeOrientation + : this.shapeOrientation; + this.shapeOrientation = data.shape === 'Box' ? 'Z' : orientation; + } else if ('shapeOrientation' in data) { + this.shapeOrientation = + this._shape === 'Box' ? 'Z' : data.shapeOrientation; + } + + if ('meshShapeResourceName' in data) + this.meshShapeResourceName = data.meshShapeResourceName || ''; + if ('shapeDimensionA' in data) + this.shapeDimensionA = data.shapeDimensionA; + if ('shapeDimensionB' in data) + this.shapeDimensionB = data.shapeDimensionB; + if ('shapeDimensionC' in data) + this.shapeDimensionC = data.shapeDimensionC; + if ('shapeOffsetX' in data) this.shapeOffsetX = data.shapeOffsetX || 0; + if ('shapeOffsetY' in data) this.shapeOffsetY = data.shapeOffsetY || 0; + if ('shapeOffsetZ' in data) this.shapeOffsetZ = data.shapeOffsetZ || 0; + if ('massCenterOffsetX' in data) + this.massCenterOffsetX = data.massCenterOffsetX || 0; + if ('massCenterOffsetY' in data) + this.massCenterOffsetY = data.massCenterOffsetY || 0; + if ('massCenterOffsetZ' in data) + this.massCenterOffsetZ = data.massCenterOffsetZ || 0; + if ('density' in data) this.density = Math.max(0.0001, data.density); + if ('massOverride' in data) this.massOverride = data.massOverride || 0; + if ('friction' in data) this.friction = data.friction; + if ('restitution' in data) this.restitution = data.restitution; + if ('linearDamping' in data) + this.linearDamping = Math.max(0, data.linearDamping); + if ('angularDamping' in data) + this.angularDamping = Math.max(0, data.angularDamping); + if ('gravityScale' in data) this.gravityScale = data.gravityScale; + if ('layers' in data) this.layers = data.layers; + if ('masks' in data) this.masks = data.masks; + } + + override applyBehaviorOverriding(diff: BehaviorData): boolean { + this._applyBehaviorData(diff); + + // Recreate the body if any shape-related property changed. + const d = diff as any; + const shapeChanged = + 'shape' in d || + 'shapeOrientation' in d || + 'shapeDimensionA' in d || + 'shapeDimensionB' in d || + 'shapeDimensionC' in d || + 'shapeOffsetX' in d || + 'shapeOffsetY' in d || + 'shapeOffsetZ' in d || + 'massCenterOffsetX' in d || + 'massCenterOffsetY' in d || + 'massCenterOffsetZ' in d || + 'meshShapeResourceName' in d || + 'bodyType' in d; + if (shapeChanged) { + this.recreateBody(); + } + return true; + } + onObjectHotReloaded() { this.updateBodyFromObject(); } diff --git a/GDJS/Runtime/RuntimeInstanceContainer.ts b/GDJS/Runtime/RuntimeInstanceContainer.ts index 88fec20e0f29..14c280f6971f 100644 --- a/GDJS/Runtime/RuntimeInstanceContainer.ts +++ b/GDJS/Runtime/RuntimeInstanceContainer.ts @@ -55,6 +55,9 @@ namespace gdjs { _debugDrawShowHiddenInstances: boolean = false; _debugDrawShowPointsNames: boolean = false; _debugDrawShowCustomPoints: boolean = false; + _debugDraw3DEnabled: boolean = false; + _debugDraw3DColorHex: integer = 0x00ff00; + _debugDraw3DDepthTest: boolean = true; _onceTriggers: OnceTriggers; @@ -248,6 +251,32 @@ namespace gdjs { this._debugDrawShowCustomPoints = showCustomPoints; } + /** + * Activate or deactivate the 3D debug visualization for collision shapes + * of objects using the built-in 3D physics behavior. + */ + enableDebugDraw3D( + enableDebugDraw: boolean, + colorHex: integer, + depthTest: boolean + ): void { + const settingsChanged = + this._debugDraw3DColorHex !== colorHex || + this._debugDraw3DDepthTest !== depthTest; + if ( + this._debugDraw3DEnabled && + (!enableDebugDraw || settingsChanged) + ) { + this.getDebuggerRenderer().clearDebugDraw3D( + this.getAdhocListOfAllInstances() + ); + } + + this._debugDraw3DEnabled = enableDebugDraw; + this._debugDraw3DColorHex = colorHex; + this._debugDraw3DDepthTest = depthTest; + } + /** * Check if an object is registered, meaning that instances of it can be * created and lives in the container. diff --git a/GDJS/Runtime/debugger-client/abstract-debugger-client.ts b/GDJS/Runtime/debugger-client/abstract-debugger-client.ts index 17ade76157cf..6c5b1cc3a339 100644 --- a/GDJS/Runtime/debugger-client/abstract-debugger-client.ts +++ b/GDJS/Runtime/debugger-client/abstract-debugger-client.ts @@ -325,6 +325,18 @@ namespace gdjs { ); } } + } else if (data.command === 'set3DCollisionsShownInEditor') { + if (inGameEditor) { + const editedInstanceContainer = + inGameEditor.getEditedInstanceContainer(); + if (editedInstanceContainer) { + editedInstanceContainer.enableDebugDraw3D( + !!data.payload.enabled, + 0x00ff00, + true + ); + } + } } else if (data.command === 'setBackgroundColor') { if (inGameEditor) { const editedInstanceContainer = diff --git a/GDJS/Runtime/pixi-renderers/DebuggerPixiRenderer.ts b/GDJS/Runtime/pixi-renderers/DebuggerPixiRenderer.ts index f5697ad66156..5389db71dcdc 100644 --- a/GDJS/Runtime/pixi-renderers/DebuggerPixiRenderer.ts +++ b/GDJS/Runtime/pixi-renderers/DebuggerPixiRenderer.ts @@ -16,11 +16,14 @@ namespace gdjs { points: Record; } >; + /** State of 3D collision wireframes per tracked object. */ + _debug3DWireframeStates: Map; constructor(instanceContainer: gdjs.RuntimeInstanceContainer) { this._instanceContainer = instanceContainer; this._debugDrawRenderedObjectsPoints = {}; this._debugDraw = null; + this._debug3DWireframeStates = new Map(); } getRendererObject() { @@ -337,8 +340,426 @@ namespace gdjs { this._debugDrawContainer = null; this._debugDrawRenderedObjectsPoints = {}; } + + /** + * Render 3D wireframe meshes showing the collision shapes of objects + * using the built-in 3D physics behavior. + * @see gdjs.RuntimeInstanceContainer#enableDebugDraw3D + */ + renderDebugDraw3D( + instances: gdjs.RuntimeObject[], + colorHex: integer, + depthTest: boolean + ) { + const THREE = (window as any).THREE; + if (!THREE) return; + + const states = this._debug3DWireframeStates; + const seen: Set = new Set(); + this._visitInstancesForDebug3D(instances, colorHex, depthTest, THREE, seen); + + // Dispose wireframes whose objects were destroyed or no longer eligible. + states.forEach((_state, object) => { + if (!seen.has(object)) { + this._disposeWireframeState(object); + } + }); + } + + /** + * Recursively visit all instances (including those nested inside + * CustomRuntimeObject children containers), updating their wireframe. + * @internal + */ + _visitInstancesForDebug3D( + instances: gdjs.RuntimeObject[], + colorHex: integer, + depthTest: boolean, + THREE: any, + seen: Set + ): void { + for (let i = 0; i < instances.length; i++) { + const object = instances[i]; + if (this._updateWireframeForInstance(object, colorHex, depthTest, THREE)) { + seen.add(object); + } + const childrenContainer = (object as any).getChildrenContainer + ? (object as any).getChildrenContainer() + : null; + if (childrenContainer) { + this._visitInstancesForDebug3D( + childrenContainer.getAdhocListOfAllInstances(), + colorHex, + depthTest, + THREE, + seen + ); + } + } + } + + /** + * @returns true if a wireframe is currently tracked for this object. + * @internal + */ + _updateWireframeForInstance( + object: gdjs.RuntimeObject, + colorHex: integer, + depthTest: boolean, + THREE: any + ): boolean { + const physics3DBehavior: any = object.getBehavior('Physics3D'); + if (!physics3DBehavior) return false; + const objectAny: any = object; + const threeRendererObject: any = + objectAny.get3DRendererObject && objectAny.get3DRendererObject(); + if (!threeRendererObject) return false; + + const states = this._debug3DWireframeStates; + let state = states.get(object); + + if (physics3DBehavior._shape === 'Mesh') { + // If switching from primitive to Mesh, clear the previous primitive. + if (state && state.kind === 'primitive') { + this._disposeWireframeState(object); + state = undefined; + } + if (!state) { + state = { + kind: 'mesh', + wrapped: [], + }; + states.set(object, state); + } + this._updateMeshShapeWireframe( + state, + threeRendererObject, + colorHex, + depthTest, + THREE + ); + return true; + } + + // Primitive shape. Clear any leftover mesh-shape wireframes first. + if (state && state.kind === 'mesh') { + this._disposeWireframeState(object); + state = undefined; + } + + const character3DBehavior: any = object.getBehavior('PhysicsCharacter3D'); + const worldWidth = objectAny.getWidth(); + const worldHeight = objectAny.getHeight(); + const worldDepth = objectAny.getDepth + ? objectAny.getDepth() + : worldWidth; + + const shapeKey = + physics3DBehavior._shape + + '|' + + physics3DBehavior.shapeOrientation + + '|' + + physics3DBehavior.shapeDimensionA + + '|' + + physics3DBehavior.shapeDimensionB + + '|' + + physics3DBehavior.shapeDimensionC + + '|' + + (physics3DBehavior.shapeOffsetX || 0) + + '|' + + (physics3DBehavior.shapeOffsetY || 0) + + '|' + + (physics3DBehavior.shapeOffsetZ || 0); + + const shapeChanged = + !state || + state.kind !== 'primitive' || + worldWidth !== state.lastWorldWidth || + worldHeight !== state.lastWorldHeight || + worldDepth !== state.lastWorldDepth || + shapeKey !== state.lastShapeKey; + + if (shapeChanged) { + if (!state || state.kind !== 'primitive') { + state = { + kind: 'primitive', + mesh: null, + lastWorldWidth: worldWidth, + lastWorldHeight: worldHeight, + lastWorldDepth: worldDepth, + lastShapeKey: shapeKey, + }; + states.set(object, state); + } else { + state.lastWorldWidth = worldWidth; + state.lastWorldHeight = worldHeight; + state.lastWorldDepth = worldDepth; + state.lastShapeKey = shapeKey; + } + const primitiveState = state; + + const shape = physics3DBehavior._shape; + const orientation = physics3DBehavior.shapeOrientation; + let w = 0, + h = 0, + d = 0, + radius = 0, + totalHeight = 0; + const autoRadius = (a: number, b: number) => Math.max(a, b) / 2; + + if (shape === 'Box') { + w = + physics3DBehavior.shapeDimensionA === 0 + ? worldWidth + : physics3DBehavior.shapeDimensionA; + h = + physics3DBehavior.shapeDimensionB === 0 + ? worldHeight + : physics3DBehavior.shapeDimensionB; + d = + physics3DBehavior.shapeDimensionC === 0 + ? worldDepth + : physics3DBehavior.shapeDimensionC; + totalHeight = d; + } else if (shape === 'Sphere') { + const volumeRadius = + Math.max(worldWidth, worldHeight, worldDepth) / 2; + radius = + physics3DBehavior.shapeDimensionA === 0 + ? volumeRadius + : physics3DBehavior.shapeDimensionA; + w = h = d = radius * 2; + totalHeight = radius * 2; + } else { + let radiusRefA: number, radiusRefB: number, heightRef: number; + if (orientation === 'X') { + radiusRefA = worldHeight; + radiusRefB = worldDepth; + heightRef = worldWidth; + } else if (orientation === 'Y') { + radiusRefA = worldWidth; + radiusRefB = worldDepth; + heightRef = worldHeight; + } else { + radiusRefA = worldWidth; + radiusRefB = worldHeight; + heightRef = worldDepth; + } + radius = + physics3DBehavior.shapeDimensionA === 0 + ? autoRadius(radiusRefA, radiusRefB) + : physics3DBehavior.shapeDimensionA; + totalHeight = + physics3DBehavior.shapeDimensionB === 0 + ? heightRef + : physics3DBehavior.shapeDimensionB; + if (orientation === 'X') { + w = totalHeight; + h = d = radius * 2; + } else if (orientation === 'Y') { + h = totalHeight; + w = d = radius * 2; + } else { + d = totalHeight; + w = h = radius * 2; + } + } + + let geometry; + if (shape === 'Box') { + geometry = new THREE.BoxGeometry(w, h, d); + } else if (shape === 'Sphere') { + geometry = new THREE.SphereGeometry(radius, 12, 12); + } else if (shape === 'Capsule') { + const cylinderHeight = Math.max(0, totalHeight - radius * 2); + geometry = new THREE.CapsuleGeometry(radius, cylinderHeight, 2, 12); + } else { + geometry = new THREE.CylinderGeometry( + radius, + radius, + totalHeight, + 12 + ); + } + + if (shape !== 'Box' && shape !== 'Sphere') { + if (orientation === 'Z') geometry.rotateX(Math.PI / 2); + if (orientation === 'X') geometry.rotateZ(Math.PI / 2); + } + + if (primitiveState.mesh) { + primitiveState.mesh.geometry.dispose(); + primitiveState.mesh.geometry = geometry; + } else { + const material = new THREE.MeshBasicMaterial({ + color: colorHex, + wireframe: true, + depthTest: depthTest, + }); + const mesh = new THREE.Mesh(geometry, material); + mesh.raycast = function () {}; + primitiveState.mesh = mesh; + threeRendererObject.add(mesh); + } + + const mesh = primitiveState.mesh; + const invScaleX = 1 / threeRendererObject.scale.x; + const invScaleY = 1 / threeRendererObject.scale.y; + const invScaleZ = 1 / threeRendererObject.scale.z; + mesh.scale.set(invScaleX, invScaleY, invScaleZ); + + let offsetX = physics3DBehavior.shapeOffsetX || 0; + let offsetY = physics3DBehavior.shapeOffsetY || 0; + let offsetZ = physics3DBehavior.shapeOffsetZ || 0; + + if (character3DBehavior) { + const halfTotal = totalHeight / 2; + if (shape === 'Box') { + offsetZ += halfTotal; + } else if (shape === 'Sphere') { + offsetZ += radius; + } else if (orientation === 'Z') { + offsetZ += halfTotal; + } else { + offsetZ += radius; + } + } + + mesh.position.set( + offsetX * invScaleX, + offsetY * invScaleY, + offsetZ * invScaleZ + ); + } + + const primitive = state as PrimitiveDebug3DState; + const mesh = primitive.mesh; + if (!mesh) return true; + mesh.visible = true; + const material = mesh.material; + if (material.depthTest !== depthTest) { + material.depthTest = depthTest; + material.needsUpdate = true; + } + if (material.color && material.color.getHex() !== colorHex) { + material.color.setHex(colorHex); + } + return true; + } + + /** @internal */ + _updateMeshShapeWireframe( + state: MeshShapeDebug3DState, + threeRendererObject: any, + colorHex: integer, + depthTest: boolean, + THREE: any + ) { + // Detect new source meshes (e.g. Model3D loaded asynchronously) and + // wrap them with a wireframe LineSegments child. Also prune entries + // whose source mesh was detached/replaced internally. + const wrapped = state.wrapped; + for (let i = wrapped.length - 1; i >= 0; i--) { + const entry = wrapped[i]; + if (!entry.source.parent && entry.source !== threeRendererObject) { + // Source mesh detached from the scene graph; dispose its wireframe. + if (entry.lines.parent) entry.lines.parent.remove(entry.lines); + entry.lines.geometry.dispose(); + entry.lines.material.dispose(); + wrapped.splice(i, 1); + } + } + + const known: Set = new Set(wrapped.map((e) => e.source)); + threeRendererObject.traverse((child: any) => { + if (child.userData && child.userData.isDebugWireframe) return; + if (!child.isMesh || !child.geometry) return; + if (known.has(child)) return; + + const wireGeometry = new THREE.WireframeGeometry(child.geometry); + const material = new THREE.LineBasicMaterial({ + color: colorHex, + depthTest: depthTest, + }); + const lines = new THREE.LineSegments(wireGeometry, material); + lines.userData.isDebugWireframe = true; + // Prevent raycasters (e.g. community Raycaster3D extension) from + // hitting the debug wireframe — LineSegments intersections lack + // `normal` and would crash consumers that expect it. + lines.raycast = function () {}; + child.add(lines); + wrapped.push({ source: child, lines }); + }); + + // Per-frame sync of visibility, depth test and color. + for (let i = 0; i < wrapped.length; i++) { + const lines = wrapped[i].lines; + lines.visible = true; + const material = lines.material; + if (material.depthTest !== depthTest) { + material.depthTest = depthTest; + material.needsUpdate = true; + } + if (material.color && material.color.getHex() !== colorHex) { + material.color.setHex(colorHex); + } + } + } + + /** @internal */ + _disposeWireframeState(object: gdjs.RuntimeObject): void { + const state = this._debug3DWireframeStates.get(object); + if (!state) return; + if (state.kind === 'primitive') { + const mesh = state.mesh; + if (mesh) { + if (mesh.parent) mesh.parent.remove(mesh); + if (mesh.geometry) mesh.geometry.dispose(); + if (mesh.material) mesh.material.dispose(); + } + } else { + for (let i = 0; i < state.wrapped.length; i++) { + const entry = state.wrapped[i]; + if (entry.lines.parent) entry.lines.parent.remove(entry.lines); + entry.lines.geometry.dispose(); + entry.lines.material.dispose(); + } + } + this._debug3DWireframeStates.delete(object); + } + + /** + * Remove all 3D debug wireframes. + * The `instances` parameter is unused (kept for API compatibility); + * the renderer tracks wireframe owners internally. + */ + clearDebugDraw3D(_instances: gdjs.RuntimeObject[]): void { + const objects: gdjs.RuntimeObject[] = []; + this._debug3DWireframeStates.forEach((_state, object) => { + objects.push(object); + }); + for (let i = 0; i < objects.length; i++) { + this._disposeWireframeState(objects[i]); + } + } } + type PrimitiveDebug3DState = { + kind: 'primitive'; + mesh: any; + lastWorldWidth: float; + lastWorldHeight: float; + lastWorldDepth: float; + lastShapeKey: string; + }; + + type MeshShapeDebug3DState = { + kind: 'mesh'; + wrapped: Array<{ source: any; lines: any }>; + }; + + type DebugDraw3DState = PrimitiveDebug3DState | MeshShapeDebug3DState; + // Register the class to let the engine use it. /** * @category Debugging > Debugger Renderer diff --git a/GDJS/Runtime/runtimescene.ts b/GDJS/Runtime/runtimescene.ts index acf90459a3e2..775d8c42b679 100644 --- a/GDJS/Runtime/runtimescene.ts +++ b/GDJS/Runtime/runtimescene.ts @@ -484,6 +484,14 @@ namespace gdjs { ); } + if (this._debugDraw3DEnabled) { + this._debuggerRenderer.renderDebugDraw3D( + this.getAdhocListOfAllInstances(), + this._debugDraw3DColorHex, + this._debugDraw3DDepthTest + ); + } + this._renderer.render(); } diff --git a/newIDE/app/src/LayersList/index.js b/newIDE/app/src/LayersList/index.js index be33c357bc36..52f311048e71 100644 --- a/newIDE/app/src/LayersList/index.js +++ b/newIDE/app/src/LayersList/index.js @@ -43,6 +43,7 @@ import { type GDevelopTheme } from '../UI/Theme'; import { type HTMLDataset } from '../Utils/HTMLDataset'; import LightbulbIconOn from '../UI/CustomSvgIcons/LightbulbOn'; import LightbulbIconOff from '../UI/CustomSvgIcons/LightbulbOff'; +import DebugIcon from '../UI/CustomSvgIcons/Debug'; import { mapReverseFor } from '../Utils/MapFor'; import { addDefaultLightToLayer } from '../ProjectCreation/CreateProject'; @@ -266,6 +267,8 @@ type Props = {| onLayerRenamed: () => void, onCreateLayer: () => void, onLayersVisibilityInEditorChanged: () => void, + areCollisionsShownInEditor: boolean, + onToggleCollisionsShownInEditor: () => void, onBackgroundColorChanged: () => void, gameEditorMode: 'embedded-game' | 'instances-editor', @@ -296,6 +299,8 @@ const LayersList = React.forwardRef( onLayerRenamed, onCreateLayer, onLayersVisibilityInEditorChanged, + areCollisionsShownInEditor, + onToggleCollisionsShownInEditor, onBackgroundColorChanged, gameEditorMode, hotReloadPreviewButtonProps, @@ -617,6 +622,16 @@ const LayersList = React.forwardRef( id: 'show-effects-button', } : null, + gameEditorMode === 'embedded-game' + ? { + icon: , + label: areCollisionsShownInEditor + ? i18n._(t`Hide 3D collision shapes in the editor`) + : i18n._(t`Show 3D collision shapes in the editor`), + click: onToggleCollisionsShownInEditor, + id: 'show-3d-collisions-button', + } + : null, { icon: , label: i18n._(t`Add a layer`), @@ -688,6 +703,8 @@ const LayersList = React.forwardRef( addLayer, layout, onLayersVisibilityInEditorChanged, + areCollisionsShownInEditor, + onToggleCollisionsShownInEditor, forceUpdate, isLightingLayerPresent, addLightingLayer, diff --git a/newIDE/app/src/ObjectEditor/CompactObjectPropertiesEditor/index.js b/newIDE/app/src/ObjectEditor/CompactObjectPropertiesEditor/index.js index a1cbafbd48a6..a603e0610959 100644 --- a/newIDE/app/src/ObjectEditor/CompactObjectPropertiesEditor/index.js +++ b/newIDE/app/src/ObjectEditor/CompactObjectPropertiesEditor/index.js @@ -24,6 +24,7 @@ import { ColumnStackLayout, LineStackLayout } from '../../UI/Layout'; import { IconContainer } from '../../UI/IconContainer'; import RemoveIcon from '../../UI/CustomSvgIcons/Remove'; import useForceUpdate from '../../Utils/UseForceUpdate'; +import { useDebounce } from '../../Utils/UseDebounce'; import ChevronArrowRight from '../../UI/CustomSvgIcons/ChevronArrowRight'; import ChevronArrowBottom from '../../UI/CustomSvgIcons/ChevronArrowBottom'; import ChevronArrowDownWithRoundedBorder from '../../UI/CustomSvgIcons/ChevronArrowDownWithRoundedBorder'; @@ -315,6 +316,11 @@ export const CompactObjectPropertiesEditor = ({ isBehaviorListLocked, }: Props): React.Node => { const forceUpdate = useForceUpdate(); + // Debounced to avoid one hot reload per keystroke on fields. + const debouncedNotifyBehaviorUpdated = useDebounce( + (objectToNotify: gdObject) => onObjectsModified([objectToNotify]), + 250 + ); const [isPropertiesFolded, setIsPropertiesFolded] = React.useState(false); const [isBehaviorsFolded, setIsBehaviorsFolded] = React.useState(false); const [isVariablesFolded, setIsVariablesFolded] = React.useState(false); @@ -826,8 +832,10 @@ export const CompactObjectPropertiesEditor = ({ behaviorOverriding={null} initialInstance={null} object={object} + onBehaviorUpdated={() => + debouncedNotifyBehaviorUpdated(object) + } layersContainer={layersContainer} - onBehaviorUpdated={() => {}} resourceManagementProps={resourceManagementProps} onOpenFullEditor={() => onEditObject(object, 'behaviors') diff --git a/newIDE/app/src/SceneEditor/EditorsDisplay.flow.js b/newIDE/app/src/SceneEditor/EditorsDisplay.flow.js index 85f2e67834d4..7ad37e066469 100644 --- a/newIDE/app/src/SceneEditor/EditorsDisplay.flow.js +++ b/newIDE/app/src/SceneEditor/EditorsDisplay.flow.js @@ -75,6 +75,8 @@ export type SceneEditorsDisplayProps = {| onLayerRenamed: () => void, onLayersModified: () => void, onLayersVisibilityInEditorChanged: () => void, + areCollisionsShownInEditor: boolean, + onToggleCollisionsShownInEditor: () => void, onBackgroundColorChanged: () => void, onObjectCreated: ( objects: Array, diff --git a/newIDE/app/src/SceneEditor/MosaicEditorsDisplay/index.js b/newIDE/app/src/SceneEditor/MosaicEditorsDisplay/index.js index cdd3407411c0..0b395d51fc45 100644 --- a/newIDE/app/src/SceneEditor/MosaicEditorsDisplay/index.js +++ b/newIDE/app/src/SceneEditor/MosaicEditorsDisplay/index.js @@ -368,6 +368,10 @@ const MosaicEditorsDisplay: React.ComponentType<{ onLayersVisibilityInEditorChanged={ props.onLayersVisibilityInEditorChanged } + areCollisionsShownInEditor={props.areCollisionsShownInEditor} + onToggleCollisionsShownInEditor={ + props.onToggleCollisionsShownInEditor + } onRemoveLayer={props.onRemoveLayer} onLayerRenamed={props.onLayerRenamed} onCreateLayer={forceUpdatePropertiesEditor} diff --git a/newIDE/app/src/SceneEditor/SwipeableDrawerEditorsDisplay/index.js b/newIDE/app/src/SceneEditor/SwipeableDrawerEditorsDisplay/index.js index 831b9c7adf0e..0c9a739f7c1e 100644 --- a/newIDE/app/src/SceneEditor/SwipeableDrawerEditorsDisplay/index.js +++ b/newIDE/app/src/SceneEditor/SwipeableDrawerEditorsDisplay/index.js @@ -575,6 +575,12 @@ const SwipeableDrawerEditorsDisplay: React.ComponentType<{ onLayersVisibilityInEditorChanged={ props.onLayersVisibilityInEditorChanged } + areCollisionsShownInEditor={ + props.areCollisionsShownInEditor + } + onToggleCollisionsShownInEditor={ + props.onToggleCollisionsShownInEditor + } onEditLayer={props.editLayer} onRemoveLayer={props.onRemoveLayer} onLayerRenamed={props.onLayerRenamed} diff --git a/newIDE/app/src/SceneEditor/index.js b/newIDE/app/src/SceneEditor/index.js index 65838ea6a1cc..03c52aac37d0 100644 --- a/newIDE/app/src/SceneEditor/index.js +++ b/newIDE/app/src/SceneEditor/index.js @@ -255,6 +255,7 @@ type State = {| setupGridOpen: boolean, scenePropertiesDialogOpen: boolean, layersListOpen: boolean, + areCollisionsShownInEditor: boolean, onCloseLayerRemoveDialog: ?( doRemove: boolean, newLayer: string | null @@ -318,6 +319,7 @@ export default class SceneEditor extends React.Component { setupGridOpen: false, scenePropertiesDialogOpen: false, layersListOpen: false, + areCollisionsShownInEditor: false, onCloseLayerRemoveDialog: null, layerRemoved: null, editedLayer: null, @@ -1757,6 +1759,22 @@ export default class SceneEditor extends React.Component { this._sendHotReloadLayers(); }; + _onToggleCollisionsShownInEditor = () => { + const next = !this.state.areCollisionsShownInEditor; + this.setState({ areCollisionsShownInEditor: next }, () => { + const { previewDebuggerServer } = this.props; + if (!previewDebuggerServer) return; + previewDebuggerServer + .getExistingEmbeddedGameFrameDebuggerIds() + .forEach(debuggerId => { + previewDebuggerServer.sendMessage(debuggerId, { + command: 'set3DCollisionsShownInEditor', + payload: { enabled: next }, + }); + }); + }); + }; + _onChooseLayer = (layerName: string) => { this.setState({ chosenLayer: layerName, @@ -2986,6 +3004,12 @@ export default class SceneEditor extends React.Component { onLayersVisibilityInEditorChanged={ this._onLayersVisibilityInEditorChanged } + areCollisionsShownInEditor={ + this.state.areCollisionsShownInEditor + } + onToggleCollisionsShownInEditor={ + this._onToggleCollisionsShownInEditor + } onRemoveLayer={this._onRemoveLayer} tileMapTileSelection={this.state.tileMapTileSelection} onSelectTileMapTile={this.onSelectTileMapTile} diff --git a/newIDE/app/src/stories/componentStories/LayoutEditor/LayersList.stories.js b/newIDE/app/src/stories/componentStories/LayoutEditor/LayersList.stories.js index 5dd19389459a..b8f417d9c203 100644 --- a/newIDE/app/src/stories/componentStories/LayoutEditor/LayersList.stories.js +++ b/newIDE/app/src/stories/componentStories/LayoutEditor/LayersList.stories.js @@ -36,6 +36,10 @@ export const Default = (): React.Node => { onLayersVisibilityInEditorChanged={action( 'onLayersVisibilityInEditorChanged' )} + areCollisionsShownInEditor={false} + onToggleCollisionsShownInEditor={action( + 'onToggleCollisionsShownInEditor' + )} onEditLayer={action('onEditLayer')} onRemoveLayer={(layerName, cb) => { cb(true); @@ -73,6 +77,10 @@ export const SmallWidthAndHeight = (): React.Node => { onLayersVisibilityInEditorChanged={action( 'onLayersVisibilityInEditorChanged' )} + areCollisionsShownInEditor={false} + onToggleCollisionsShownInEditor={action( + 'onToggleCollisionsShownInEditor' + )} onEditLayer={action('onEditLayer')} onRemoveLayer={(layerName, cb) => { cb(true);