Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implements a mobile virtual joystick #111

Merged
merged 1 commit into from
Feb 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
}
Loading