UNPKG

@polygonjs/polygonjs

Version:

node-based WebGL 3D engine https://polygonjs.com

366 lines (346 loc) 11.5 kB
/** * Creates a virtual joystick for mobile first person controls * * @remarks * Ensure to have the following tag in your html `<head>`: * * `<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />` * * as this will prevent the screen from zooming in and out when using a finger to translate and another to rotate. * * And if you are using an iphone, you may need to also add the following css: * - to prevent page reload when swiping down: * * ``` css *body { * overscroll-behavior-y: none; * position: fixed; * overflow: hidden; *} *``` * * - and to disable page zoom from double tap or when using 2 fingers: * *``` css *body { * touch-action: none; *} *``` * */ import {Camera} from 'three'; import {TypedCameraControlsEventNode} from './_BaseCameraControls'; import {NodeParamsConfig, ParamConfig} from '../utils/params/ParamsConfig'; import {EventConnectionPoint, EventConnectionPointType} from '../utils/io/connections/Event'; import { MobileJoystickControls, MobileJoystickControlsOptions, DEFAULT_PARAMS, } from '../../../modules/core/controls/MobileJoystickControls'; import {CameraControlsNodeType, NodeContext} from '../../poly/NodeContext'; import {BaseNodeType} from '../_Base'; import {CorePlayer, CorePlayerOptions} from '../../../core/player/Player'; import {ParamOptions} from '../../params/utils/OptionsController'; import {isBooleanTrue} from '../../../core/Type'; import {CollisionController} from './collision/CollisionController'; import {CapsuleSopOperation} from '../../operations/sop/Capsule'; const EVENT_START = 'start'; const EVENT_CHANGE = 'change'; const EVENT_END = 'end'; function updatePlayerParamsCallbackOption(): ParamOptions { return { cook: false, callback: (node: BaseNodeType) => { MobileJoystickControlsEventNode.PARAM_CALLBACK_updatePlayerParams(node as MobileJoystickControlsEventNode); }, }; } type MobileJoystickControlsMap = Map<string, MobileJoystickControls>; interface GetElementOptions { actionAllowed: boolean; customElement: boolean; selector: string; } class MobileJoystickEventParamsConfig extends NodeParamsConfig { main = ParamConfig.FOLDER(); /** @param collider object */ colliderObject = ParamConfig.NODE_PATH('', { nodeSelection: { context: NodeContext.SOP, }, // if the node is dependent, // the MobileJoystickControlsEventNode will be re-created when this node changes // which we do not want, as it will act like a hard reset // when all we want is to update the collider dependentOnFoundNode: false, callback: (node: BaseNodeType) => { MobileJoystickControlsEventNode.PARAM_CALLBACK_updateCollider(node as MobileJoystickControlsEventNode); }, }); /** @param collision Capsule Radius */ capsuleRadius = ParamConfig.FLOAT(CapsuleSopOperation.DEFAULT_PARAMS.radius, { range: [0, 1], rangeLocked: [true, false], ...updatePlayerParamsCallbackOption(), }); /** @param collision Capsule Height */ capsuleHeight = ParamConfig.FLOAT(CapsuleSopOperation.DEFAULT_PARAMS.height, { range: [0, 2], rangeLocked: [true, false], ...updatePlayerParamsCallbackOption(), }); physics = ParamConfig.FOLDER(); /** @param physics Steps */ physicsSteps = ParamConfig.INTEGER(5, { range: [1, 10], rangeLocked: [true, false], ...updatePlayerParamsCallbackOption(), }); /** @param gravity */ gravity = ParamConfig.VECTOR3([0, -30, 0], { ...updatePlayerParamsCallbackOption(), }); /** @param translation speed */ translateSpeed = ParamConfig.FLOAT(1); /** @param rotation speed */ rotateSpeed = ParamConfig.FLOAT(DEFAULT_PARAMS.rotateSpeed); /** @param specify a custom HTML element */ customTranslateElement = ParamConfig.BOOLEAN(false, { separatorBefore: true, }); /** @param jump HTML element selector */ translateElementSelector = ParamConfig.STRING('#translate-element', { visibleIf: { customTranslateElement: true, }, }); /** @param jump Allowed */ jumpAllowed = ParamConfig.BOOLEAN(true, { separatorBefore: true, ...updatePlayerParamsCallbackOption(), }); /** @param jump Force */ jumpStrength = ParamConfig.FLOAT(10, { range: [0, 100], rangeLocked: [true, false], ...updatePlayerParamsCallbackOption(), }); /** @param specify a custom HTML element */ customJumpElement = ParamConfig.BOOLEAN(false, { visibleIf: { jumpAllowed: true, }, }); /** @param jump HTML element selector */ jumpElementSelector = ParamConfig.STRING('#jump-element', { visibleIf: { jumpAllowed: true, customJumpElement: true, }, }); /** @param run Allowed */ runAllowed = ParamConfig.BOOLEAN(true, { separatorBefore: true, ...updatePlayerParamsCallbackOption(), }); /** @param run speed mult */ runSpeedMult = ParamConfig.FLOAT(2, { range: [0, 10], rangeLocked: [true, false], ...updatePlayerParamsCallbackOption(), }); /** @param specify a custom HTML element */ customRunElement = ParamConfig.BOOLEAN(false, { visibleIf: { runAllowed: true, }, }); /** @param jump HTML element selector */ runElementSelector = ParamConfig.STRING('#run-element', { visibleIf: { runAllowed: true, customRunElement: true, }, }); /** @param recompute colliding geo */ updateCollider = ParamConfig.BUTTON(null, { separatorBefore: true, callback: (node: BaseNodeType) => { MobileJoystickControlsEventNode.PARAM_CALLBACK_updateCollider(node as MobileJoystickControlsEventNode); }, }); init = ParamConfig.FOLDER(); /** @param start Position */ startPosition = ParamConfig.VECTOR3([0, 2, 0], { ...updatePlayerParamsCallbackOption(), }); /** @param start Position */ startRotation = ParamConfig.VECTOR3([0, 0, 0], { ...updatePlayerParamsCallbackOption(), }); /** @param reset */ reset = ParamConfig.BUTTON(null, { callback: (node: BaseNodeType) => { MobileJoystickControlsEventNode.PARAM_CALLBACK_resetPlayer(node as MobileJoystickControlsEventNode); }, }); /** @param min polar angle */ minPolarAngle = ParamConfig.FLOAT('-$PI*0.5', { range: [-Math.PI, Math.PI], rangeLocked: [true, true], }); /** @param max polar angle */ maxPolarAngle = ParamConfig.FLOAT('$PI*0.5', { range: [-Math.PI, Math.PI], rangeLocked: [true, true], }); } const ParamsConfig = new MobileJoystickEventParamsConfig(); export class MobileJoystickControlsEventNode extends TypedCameraControlsEventNode<MobileJoystickEventParamsConfig> { override paramsConfig = ParamsConfig; static override type() { return CameraControlsNodeType.MOBILE_JOYSTICK; } endEventName() { return 'end'; } static readonly INPUT_UPDATE_COLLIDER = 'updateCollider'; private _collisionController: CollisionController | undefined; collisionController(): CollisionController { return (this._collisionController = this._collisionController || new CollisionController(this)); } override initializeNode() { this.io.inputs.setNamedInputConnectionPoints([ new EventConnectionPoint( MobileJoystickControlsEventNode.INPUT_UPDATE_COLLIDER, EventConnectionPointType.BASE, this._updateCollider.bind(this) ), ]); this.io.outputs.setNamedOutputConnectionPoints([ new EventConnectionPoint(EVENT_START, EventConnectionPointType.BASE), new EventConnectionPoint(EVENT_CHANGE, EventConnectionPointType.BASE), new EventConnectionPoint(EVENT_END, EventConnectionPointType.BASE), ]); } private _controls_by_element_id: MobileJoystickControlsMap = new Map(); private _player: CorePlayer | undefined; async createControlsInstance(camera: Camera, element: HTMLElement) { await this._initPlayer(camera); function _getElement(options: GetElementOptions) { if (!options.actionAllowed) { return; } if (!options.customElement) { return; } return document.querySelector<HTMLElement>(options.selector) || undefined; } const translateDomElement = _getElement({ actionAllowed: true, customElement: this.pv.customTranslateElement, selector: this.pv.translateElementSelector, }); const runDomElement = _getElement({ actionAllowed: this.pv.runAllowed, customElement: this.pv.customRunElement, selector: this.pv.runElementSelector, }); const jumpDomElement = _getElement({ actionAllowed: this.pv.jumpAllowed, customElement: this.pv.customJumpElement, selector: this.pv.jumpElementSelector, }); const options: MobileJoystickControlsOptions = { translateDomElement, runDomElement, jumpDomElement, }; const controls = new MobileJoystickControls(camera, element, options, this._player); this._controls_by_element_id.set(element.id, controls); this._bind_listeners_to_controls_instance(controls); return controls; } private async _initPlayer(camera: Camera) { const options = await this._playerOptions(camera); if (!options) { return; } this._player = this._player || new CorePlayer(options); // we need to make sure the player is updated with new camera/collision when those change this._player.setOptions(options); this._updatePlayerParams(); this._player.reset(); } private async _playerOptions(camera: Camera): Promise<CorePlayerOptions | undefined> { const collider = await this.collisionController().getCollider(); if (!collider) { this.states.error.set('invalid collider'); return; } return {object: camera, collider: collider}; } player() { return this._player; } private async _updatePlayerParams() { if (!this._player) { return; } this._player.startPosition.copy(this.pv.startPosition); this._player.physicsSteps = this.pv.physicsSteps; this._player.jumpAllowed = isBooleanTrue(this.pv.jumpAllowed); this._player.jumpStrength = this.pv.jumpStrength; this._player.runAllowed = isBooleanTrue(this.pv.runAllowed); this._player.runSpeedMult = this.pv.runSpeedMult; this._player.gravity.copy(this.pv.gravity); this._player.speed = this.pv.translateSpeed; this._player.setCapsule({ radius: this.pv.capsuleRadius, height: this.pv.capsuleHeight, divisions: 5, center: CapsuleSopOperation.DEFAULT_PARAMS.center, }); this._controls_by_element_id.forEach((controls) => controls.updateElements()); } private _resetPlayer() { this._player?.reset(); } private async _updateCollider() { await this.collisionController().updateCollider(); } protected _bind_listeners_to_controls_instance(controls: MobileJoystickControls) { controls.addEventListener(EVENT_START, () => { this.dispatchEventToOutput(EVENT_START, {}); }); controls.addEventListener(EVENT_CHANGE, () => { this.dispatchEventToOutput(EVENT_CHANGE, {}); }); controls.addEventListener(EVENT_END, () => { this.dispatchEventToOutput(EVENT_END, {}); }); } updateRequired() { return true; } setupControls(controls: MobileJoystickControls) { controls.setRotationSpeed(this.pv.rotateSpeed); controls.setRotationRange({min: this.pv.minPolarAngle, max: this.pv.maxPolarAngle}); controls.updateElements(); } disposeControlsForHtmlElementId(html_element_id: string) { const controls = this._controls_by_element_id.get(html_element_id); if (controls) { this._controls_by_element_id.delete(html_element_id); } } static PARAM_CALLBACK_updateCollider(node: MobileJoystickControlsEventNode) { node._updateCollider(); } static PARAM_CALLBACK_updatePlayerParams(node: MobileJoystickControlsEventNode) { node._updatePlayerParams(); } static PARAM_CALLBACK_resetPlayer(node: MobileJoystickControlsEventNode) { node._resetPlayer(); } }