Add support for touch controls in editor

This commit is contained in:
Florian Rival
2025-09-28 15:31:58 +02:00
parent c2f17f9348
commit d924cb4269
2 changed files with 255 additions and 29 deletions

View File

@@ -33,6 +33,7 @@ namespace gdjs {
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;
@@ -181,6 +182,44 @@ namespace gdjs {
}
};
/** 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,
@@ -1129,9 +1168,6 @@ namespace gdjs {
this._getEditorCamera().updateCamera(currentScene, layer);
});
// TODO: touch controls - pinch to zoom
// TODO: touch controls - two fingers to move the camera
}
moveSelectionUnderCursor() {
@@ -1350,10 +1386,14 @@ namespace gdjs {
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)
!isSpacePressed(inputManager) &&
!hasMultipleTouches
) {
if (this._wasMouseLeftButtonPressed && this._selectionBox) {
this._selectionBox.endPoint.set(
@@ -1400,7 +1440,8 @@ namespace gdjs {
) {
if (
!this._selectionBox.endPoint.equals(this._selectionBox.startPoint) &&
!this._hasSelectionActuallyMoved
!this._hasSelectionActuallyMoved &&
!hasMultipleTouches
) {
// Selection rectangle ended.
@@ -1638,6 +1679,9 @@ namespace gdjs {
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({
@@ -1645,6 +1689,11 @@ namespace gdjs {
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 (
@@ -1653,7 +1702,7 @@ namespace gdjs {
(lastEditableSelectedObject &&
this._selectionControls.object !== lastEditableSelectedObject) ||
this._shouldDragSelectedObject() ||
isSpacePressed(inputManager))
shouldHideSelectionControls)
) {
this._removeSelectionControls();
}
@@ -1663,7 +1712,7 @@ namespace gdjs {
lastEditableSelectedObject &&
!this._selectionControls &&
!this._shouldDragSelectedObject() &&
!isSpacePressed(inputManager)
!shouldHideSelectionControls
) {
const threeObject = lastEditableSelectedObject.get3DRendererObject();
if (!threeObject) return;
@@ -2877,6 +2926,26 @@ namespace gdjs {
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);
@@ -2904,7 +2973,10 @@ namespace gdjs {
step(): void {
const runtimeGame = this.editor.getRuntimeGame();
const inputManager = runtimeGame._inputManager;
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.
@@ -2925,6 +2997,27 @@ namespace gdjs {
) {
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();
@@ -3162,6 +3255,10 @@ namespace gdjs {
private _wasMouseRightButtonPressed = false;
private _wasMouseMiddleButtonPressed = false;
private _gestureActiveTouchIds: Array<integer> = [];
private _gestureLastCentroidX: float = 0;
private _gestureLastCentroidY: float = 0;
constructor(editorCamera: EditorCamera) {
this._editorCamera = editorCamera;
}
@@ -3177,7 +3274,7 @@ namespace gdjs {
step(): void {
const runtimeGame = this._editorCamera.editor.getRuntimeGame();
const inputManager = runtimeGame._inputManager;
const inputManager = runtimeGame.getInputManager();
if (this._isEnabled) {
// Right click: rotate the camera.
// Middle click: also rotate the camera.
@@ -3204,6 +3301,49 @@ namespace gdjs {
this.distance = Math.max(10, this.distance - wheelDeltaY);
this._editorCamera.onHasCameraChanged();
}
// 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 < 5) this.elevationAngle = 5;
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 = inputManager.isMouseButtonPressed(1);
@@ -3224,29 +3364,67 @@ namespace gdjs {
return this.target.z;
}
private _getCameraForwardVector(): [float, float, float] {
// 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 {
return (
this.target.x +
this.distance *
Math.cos(gdjs.toRad(this.rotationAngle + 90)) *
Math.cos(gdjs.toRad(this.elevationAngle))
);
const [fwdX, ,] = this._getCameraForwardVector();
return this.target.x - this.distance * fwdX;
}
getCameraY(): float {
return (
this.target.y +
this.distance *
Math.sin(gdjs.toRad(this.rotationAngle + 90)) *
Math.cos(gdjs.toRad(this.elevationAngle))
);
const [, fwdY] = this._getCameraForwardVector();
return this.target.y - this.distance * fwdY;
}
getCameraZ(): float {
return (
this.target.z +
this.distance * Math.sin(gdjs.toRad(this.elevationAngle))
);
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 {
@@ -3310,6 +3488,12 @@ namespace gdjs {
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;
}
@@ -3325,7 +3509,7 @@ namespace gdjs {
step(): void {
const runtimeGame = this._editorCamera.editor.getRuntimeGame();
const inputManager = runtimeGame._inputManager;
const inputManager = runtimeGame.getInputManager();
if (this._isEnabled) {
const { right, up, forward } = this.getCameraVectors();
@@ -3351,6 +3535,45 @@ namespace gdjs {
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;
@@ -3397,11 +3620,11 @@ namespace gdjs {
}
// Space + click: move the camera on its plane.
// Wheel click: same.
// Shift + Wheel click: same.
if (
(isSpacePressed(inputManager) &&
inputManager.isMouseButtonPressed(0)) ||
inputManager.isMouseButtonPressed(2)
(isShiftPressed(inputManager) && inputManager.isMouseButtonPressed(2))
) {
const xDelta = this._lastCursorX - inputManager.getCursorX();
const yDelta = this._lastCursorY - inputManager.getCursorY();
@@ -3423,6 +3646,9 @@ namespace gdjs {
this.elevationAngle += yDelta * rotationSpeed;
this._editorCamera.onHasCameraChanged();
}
} else {
// Reset gesture tracking when camera control is disabled.
this._gestureActiveTouchIds = [];
}
this._wasMouseRightButtonPressed = inputManager.isMouseButtonPressed(1);
this._lastCursorX = inputManager.getCursorX();

View File

@@ -230,7 +230,7 @@ namespace gdjs {
_hasJustResumed: boolean = false;
//Inputs :
_inputManager: InputManager;
private _inputManager: InputManager;
_options: RuntimeGameOptions;