Files
GDevelop/GDJS/Runtime/InGameEditor/InGameEditor.tsx

5139 lines
171 KiB
TypeScript

namespace gdjs {
const logger = new gdjs.Logger('In-Game editor');
/**
* A minimal utility to define DOM elements.
* Also copied in InGameDebugger.tsx.
*/
function h<K extends keyof HTMLElementTagNameMap>(
tag: K,
attrs: {
style?: Partial<CSSStyleDeclaration>;
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<THREE.Mesh> = [];
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<T>(value: T | null | undefined): value is NonNullable<T> {
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<integer>, ids2: Array<integer>) => {
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<gdjs.RuntimeObject> = [];
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 {
private editor: InGameEditor;
private _changeHappened = false;
private startTime = 0;
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();
this.startTime = Date.now();
}
endMove(): boolean {
const changeHappened = this._changeHappened;
this._objectInitialPositions.clear();
this._changeHappened = false;
return changeHappened;
}
move(
selectedObjects: Array<gdjs.RuntimeObject>,
movement: {
translationX: float;
translationY: float;
translationZ: float;
rotationX: float;
rotationY: float;
rotationZ: float;
scaleX: float;
scaleY: float;
scaleZ: float;
}
) {
if (Date.now() - this.startTime < 150) {
// Avoid miss-clicks gizmo dragging point to change object positions.
return;
}
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<RuntimeObject, ObjectSelectionBoxHelper> =
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<LayerData>,
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<string>;
let editedLayerDataList: Array<LayerData> = [];
let editedInstanceDataList: Array<InstanceData> = [];
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<RuntimeObject>,
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<string>) {
const editedInstanceContainer = this.getEditedInstanceContainer();
if (!editedInstanceContainer) return;
const persistentUuidsSet = new Set<string>(persistentUuids);
const selectedObjectsMap = new Map<string, gdjs.RuntimeObject>();
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<gdjs.RuntimeObject> = [];
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<gdjs.RuntimeObject>();
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<gdjs.RuntimeObject>;
removedObjects?: Array<gdjs.RuntimeObject>;
objectToEdit?: gdjs.RuntimeObject | null;
}) {
const debuggerClient = this._runtimeGame._debuggerClient;
if (!debuggerClient) return;
const getPersistentUuidsFromObjects = (
objects: Array<gdjs.RuntimeObject>
): Array<InstancePersistentUuidData> =>
objects
.map((object) => {
if (!object.persistentUuid) return null;
return { persistentUuid: object.persistentUuid };
})
.filter(isDefined);
const getSelectedInstances = (
objects: Array<gdjs.RuntimeObject>
): Array<InstancePersistentUuidData> =>
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<InstanceData>) {
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<InstanceData>) {
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<InstanceData>) {
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<InstanceData>) {
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<gdjs.RuntimeObject>
): 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<gdjs.RuntimeObject>
): 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();
for (let i = 0; i < gdjs.callbacksInGameEditorPostStep.length; ++i) {
gdjs.callbacksInGameEditorPostStep[i](this);
}
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 }) => (
<span
class="InGameEditor-Toolbar-Button-Icon"
style={{
'-webkit-mask': `url('${svgIconUrl}') center/contain no-repeat`,
mask: `url('${svgIconUrl}') center/contain no-repeat`,
}}
></span>
);
if (!this._renderedElements) {
const container = (
<div class="InGameEditor-Toolbar-Centering-Container">
<div class="InGameEditor-Toolbar-Container">
<div class="InGameEditor-Toolbar-Container-Background" />
<button
class="InGameEditor-Toolbar-Button"
id="free-camera-button"
onClick={this._switchToFreeCamera}
title="Free camera mode"
>
{makeIcon({
svgIconUrl: this._getSvgIconUrl(
'InGameEditor-FreeCameraIcon'
),
})}
</button>
<button
class="InGameEditor-Toolbar-Button"
id="orbit-camera-button"
onClick={this._switchToOrbitCamera}
title="Orbit camera mode"
>
{makeIcon({
svgIconUrl: this._getSvgIconUrl(
'InGameEditor-OrbitCameraIcon'
),
})}
</button>
<div class="InGameEditor-Toolbar-Divider" />
<button
class="InGameEditor-Toolbar-Button"
id="move-button"
onClick={() => this._setTransformControlsMode('translate')}
title="Move (translate) selection (1)"
>
{makeIcon({
svgIconUrl: this._getSvgIconUrl('InGameEditor-MoveIcon'),
})}
</button>
<button
class="InGameEditor-Toolbar-Button"
id="rotate-button"
onClick={() => this._setTransformControlsMode('rotate')}
title="Rotate selection (2)"
>
{makeIcon({
svgIconUrl: this._getSvgIconUrl('InGameEditor-RotateIcon'),
})}
</button>
<button
class="InGameEditor-Toolbar-Button"
id="scale-button"
onClick={() => this._setTransformControlsMode('scale')}
title="Resize selection (3)"
>
{makeIcon({
svgIconUrl: this._getSvgIconUrl('InGameEditor-ResizeIcon'),
})}
</button>
<div class="InGameEditor-Toolbar-Divider" />
<button
class="InGameEditor-Toolbar-Button"
id="focus-button"
onClick={this._focusOnSelection}
title="Focus on selection (F)"
>
{makeIcon({
svgIconUrl: this._getSvgIconUrl('InGameEditor-FocusIcon'),
})}
</button>
</div>
</div>
);
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<RuntimeObject>,
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<integer> = [];
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<integer> = [];
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();
}
}
}