Skip to content

Commit

Permalink
implements a mobile virtual joystick (#111)
Browse files Browse the repository at this point in the history
  • Loading branch information
TheCodeTherapy authored Feb 23, 2024
1 parent b0a473a commit 484254f
Show file tree
Hide file tree
Showing 3 changed files with 295 additions and 5 deletions.
62 changes: 62 additions & 0 deletions packages/3d-web-client-core/src/camera/CameraManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { PerspectiveCamera, Raycaster, Vector3 } from "three";
import { CollisionsManager } from "../collisions/CollisionsManager";
import { remap } from "../helpers/math-helpers";
import { EventHandlerCollection } from "../input/EventHandlerCollection";
import { VirtualJoystick } from "../input/VirtualJoystick";
import { getTweakpaneActive } from "../tweakpane/tweakPaneActivity";

export class CameraManager {
Expand Down Expand Up @@ -48,6 +49,10 @@ export class CameraManager {
private lerpFactor: number = 0;
private lerpDuration: number = 2.1;

private hasTouchControl: boolean = false;
private lastTouchX: number = 0;
private lastTouchY: number = 0;

constructor(
targetElement: HTMLElement,
private collisionsManager: CollisionsManager,
Expand All @@ -61,12 +66,69 @@ export class CameraManager {
this.camera = new PerspectiveCamera(this.fov, window.innerWidth / window.innerHeight, 0.1, 400);
this.camera.position.set(0, 1.4, -this.initialDistance);
this.rayCaster = new Raycaster();

this.hasTouchControl = VirtualJoystick.checkForTouch();

this.eventHandlerCollection = EventHandlerCollection.create([
[targetElement, "mousedown", this.onMouseDown.bind(this)],
[document, "mouseup", this.onMouseUp.bind(this)],
[document, "mousemove", this.onMouseMove.bind(this)],
[targetElement, "wheel", this.onMouseWheel.bind(this)],
]);

if (this.hasTouchControl) {
this.eventHandlerCollection.add(targetElement, "touchstart", this.onTouchStart.bind(this), {
passive: false,
});
this.eventHandlerCollection.add(document, "touchmove", this.onTouchMove.bind(this), {
passive: false,
});
this.eventHandlerCollection.add(document, "touchend", this.onTouchEnd.bind(this), {
passive: false,
});
}
}

private onTouchStart(evt: TouchEvent): void {
Array.from(evt.touches).forEach((touch) => {
if (!VirtualJoystick.isTouchOnJoystick(touch)) {
this.dragging = true;
this.lastTouchX = touch.clientX;
this.lastTouchY = touch.clientY;
}
});
}

private onTouchMove(evt: TouchEvent): void {
if (!this.dragging || getTweakpaneActive()) {
return;
}
evt.preventDefault();

const touch = Array.from(evt.touches).find((t) => !VirtualJoystick.isTouchOnJoystick(t));
if (touch) {
const dx = touch.clientX - this.lastTouchX;
const dy = touch.clientY - this.lastTouchY;
this.lastTouchX = touch.clientX;
this.lastTouchY = touch.clientY;

if (this.targetTheta !== null && this.targetPhi !== null) {
this.targetTheta += dx * 0.01;
this.targetPhi -= dy * 0.01;
this.targetPhi = Math.max(this.minPolarAngle, Math.min(this.maxPolarAngle, this.targetPhi));
}
}
}

private onTouchEnd(evt: TouchEvent): void {
if (this.dragging) {
const touchEnded = Array.from(evt.changedTouches).some(
(t) => !VirtualJoystick.isTouchOnJoystick(t),
);
if (touchEnded) {
this.dragging = false;
}
}
}

private onMouseDown(): void {
Expand Down
22 changes: 17 additions & 5 deletions packages/3d-web-client-core/src/input/KeyInputManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EventHandlerCollection } from "./EventHandlerCollection";
import { VirtualJoystick } from "./VirtualJoystick";

enum Key {
W = "w",
Expand All @@ -12,11 +13,19 @@ enum Key {
export class KeyInputManager {
private keys = new Map<string, boolean>();
private eventHandlerCollection = new EventHandlerCollection();
private directionJoystick: VirtualJoystick | null = null;

constructor(private shouldCaptureKeyPress: () => boolean = () => true) {
this.eventHandlerCollection.add(document, "keydown", this.onKeyDown.bind(this));
this.eventHandlerCollection.add(document, "keyup", this.onKeyUp.bind(this));
this.eventHandlerCollection.add(window, "blur", this.handleUnfocus.bind(this));
this.directionJoystick = new VirtualJoystick({
radius: 70,
inner_radius: 20,
x: 70,
y: 0,
mouse_support: false,
});
}

private handleUnfocus(_event: FocusEvent): void {
Expand Down Expand Up @@ -47,23 +56,26 @@ export class KeyInputManager {
}

public isMovementKeyPressed(): boolean {
return [Key.W, Key.A, Key.S, Key.D].some((key) => this.isKeyPressed(key));
return (
[Key.W, Key.A, Key.S, Key.D].some((key) => this.isKeyPressed(key)) ||
this.directionJoystick!.hasDirection
);
}

get forward(): boolean {
return this.isKeyPressed(Key.W);
return this.isKeyPressed(Key.W) || this.directionJoystick!.up;
}

get backward(): boolean {
return this.isKeyPressed(Key.S);
return this.isKeyPressed(Key.S) || this.directionJoystick!.down;
}

get left(): boolean {
return this.isKeyPressed(Key.A);
return this.isKeyPressed(Key.A) || this.directionJoystick!.left;
}

get right(): boolean {
return this.isKeyPressed(Key.D);
return this.isKeyPressed(Key.D) || this.directionJoystick!.right;
}

get run(): boolean {
Expand Down
216 changes: 216 additions & 0 deletions packages/3d-web-client-core/src/input/VirtualJoystick.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
interface JoyStickAttributes {
radius?: number;
inner_radius?: number;
x?: number;
y?: number;
width?: number;
height?: number;
mouse_support?: boolean;
visible?: boolean;
anchor?: "left" | "right";
}

export class VirtualJoystick {
public static JOYSTICK_DIV: HTMLDivElement | null = null;

private radius: number;
private inner_radius: number;
private anchor: "left" | "right";
private x: number;
private y: number;
private width: number;
private height: number;
private mouse_support: boolean;

private div: HTMLDivElement;
private base: HTMLSpanElement;
private control: HTMLSpanElement;

public left: boolean = false;
public right: boolean = false;
public up: boolean = false;
public down: boolean = false;
public hasDirection: boolean = false;

constructor(attrs: JoyStickAttributes) {
this.radius = attrs.radius || 50;
this.inner_radius = attrs.inner_radius || this.radius / 2;
this.anchor = attrs.anchor || "left";
this.x = attrs.x || 0;
this.y = attrs.y || 0;
this.width = attrs.width || this.radius * 2 + this.inner_radius * 2;
this.height = attrs.height || this.radius * 2 + this.inner_radius * 2;
this.mouse_support = this.checkTouch() || attrs.mouse_support === true;

this.initializeJoystick();
}

public static checkForTouch(): boolean {
try {
document.createEvent("TouchEvent");
return true;
} catch (e) {
return false;
}
}

public static isTouchOnJoystick(touch: Touch): boolean {
if (!VirtualJoystick.JOYSTICK_DIV) {
return false;
}
const divRect = VirtualJoystick.JOYSTICK_DIV.getBoundingClientRect();
return (
touch.clientX >= divRect.left &&
touch.clientX <= divRect.right &&
touch.clientY >= divRect.top &&
touch.clientY <= divRect.bottom
);
}

private checkTouch() {
return VirtualJoystick.checkForTouch();
}

private initializeJoystick(): void {
if (!VirtualJoystick.JOYSTICK_DIV) {
this.div = document.createElement("div");
const divStyle = this.div.style;
divStyle.display = this.checkTouch() || this.mouse_support ? "visible" : "none";
divStyle.position = "fixed";
if (this.anchor === "left") {
divStyle.left = `${this.x}px`;
} else {
divStyle.right = `${this.x}px`;
}
divStyle.bottom = `${this.y}px`;
divStyle.width = `${this.width}px`;
divStyle.height = `${this.height}px`;
divStyle.zIndex = "10000";
divStyle.overflow = "hidden";
document.body.appendChild(this.div);
VirtualJoystick.JOYSTICK_DIV = this.div;
}

this.setupBaseAndControl();
this.bindEvents();
}

private setupBaseAndControl(): void {
this.base = document.createElement("span");
let divStyle = this.base.style;
divStyle.width = `${this.radius * 2}px`;
divStyle.height = `${this.radius * 2}px`;
divStyle.position = "absolute";
divStyle.left = `${this.width / 2 - this.radius}px`;
divStyle.bottom = `${this.height / 2 - this.radius}px`;
divStyle.borderRadius = "50%";
divStyle.borderColor = "rgba(200,200,200,0.5)";
divStyle.borderWidth = "2px";
divStyle.borderStyle = "solid";
this.div.appendChild(this.base);

this.control = document.createElement("span");
divStyle = this.control.style;
divStyle.width = `${this.inner_radius * 2}px`;
divStyle.height = `${this.inner_radius * 2}px`;
divStyle.position = "absolute";
divStyle.left = `${this.width / 2 - this.inner_radius}px`;
divStyle.bottom = `${this.height / 2 - this.inner_radius}px`;
divStyle.borderRadius = "50%";
divStyle.backgroundColor = "rgba(200,200,200,0.3)";
divStyle.borderWidth = "1px";
divStyle.borderColor = "rgba(200,200,200,0.8)";
divStyle.borderStyle = "solid";
this.div.appendChild(this.control);
}

private bindEvents(): void {
this.div.addEventListener("touchstart", this.handleTouchStart.bind(this), false);
this.div.addEventListener("touchmove", this.handleTouchMove.bind(this), false);
this.div.addEventListener("touchend", this.clearFlags.bind(this), false);

if (this.mouse_support) {
this.div.addEventListener("mousedown", this.handleMouseDown.bind(this));
this.div.addEventListener("mousemove", this.handleMouseMove.bind(this));
this.div.addEventListener("mouseup", this.handleMouseUp.bind(this));
}
}

private handleTouchStart(evt: TouchEvent): void {
evt.preventDefault();
if (evt.touches) {
const touch = evt.touches[0];
this.updateControlAndDirection(touch);
}
}

private handleTouchMove(evt: TouchEvent): void {
evt.preventDefault();
if (evt.touches.length > 0) {
const touch = evt.touches[0];
this.updateControlAndDirection(touch);
}
}

private handleMouseDown(evt: MouseEvent): void {
evt.preventDefault();
this.updateControlAndDirection(evt);
}

private handleMouseMove(evt: MouseEvent): void {
if (evt.buttons === 1) {
evt.preventDefault();
this.updateControlAndDirection(evt);
}
}

private handleMouseUp(evt: MouseEvent): void {
this.clearFlags();
}

private clearFlags = (): void => {
this.left = false;
this.right = false;
this.up = false;
this.down = false;
this.hasDirection = false;
this.control.style.left = `${this.width / 2 - this.inner_radius}px`;
this.control.style.top = `${this.height / 2 - this.inner_radius}px`;
};

private updateControlAndDirection(input: Touch | MouseEvent): void {
const rect = this.div.getBoundingClientRect();
const dx = input.clientX - (rect.left + this.div.offsetWidth / 2);
const dy = input.clientY - (rect.top + this.div.offsetHeight / 2);

const distance = Math.min(Math.sqrt(dx * dx + dy * dy), this.radius);
const angle = Math.atan2(dy, dx);
const constrainedX = distance * Math.cos(angle);
const constrainedY = distance * Math.sin(angle);

this.control.style.left = `${constrainedX + this.width / 2 - this.inner_radius}px`;
this.control.style.top = `${constrainedY + this.height / 2 - this.inner_radius}px`;

this.up = this.isUp(dx, dy);
this.down = this.isDown(dx, dy);
this.left = this.isLeft(dx, dy);
this.right = this.isRight(dx, dy);
this.hasDirection = this.up || this.down || this.left || this.right;
}

private isUp(dx: number, dy: number): boolean {
return dy < 0 && Math.abs(dx) <= 2 * Math.abs(dy);
}

private isDown(dx: number, dy: number): boolean {
return dy > 0 && Math.abs(dx) <= 2 * Math.abs(dy);
}

private isLeft(dx: number, dy: number): boolean {
return dx < 0 && Math.abs(dy) <= 2 * Math.abs(dx);
}

private isRight(dx: number, dy: number): boolean {
return dx > 0 && Math.abs(dy) <= 2 * Math.abs(dx);
}
}

0 comments on commit 484254f

Please sign in to comment.