diff --git a/examples/src/examples/camera/first-person.example.mjs b/examples/src/examples/camera/first-person.example.mjs index e8e7eee742f..8c3d06fc0e3 100644 --- a/examples/src/examples/camera/first-person.example.mjs +++ b/examples/src/examples/camera/first-person.example.mjs @@ -1,7 +1,14 @@ // @config DESCRIPTION
(WASD) Move
(Space) Jump
(Mouse) Look
-import { deviceType, rootPath } from 'examples/utils'; +import { deviceType, fileImport, rootPath } from 'examples/utils'; import * as pc from 'playcanvas'; +const { + DesktopInput, + MobileInput, + GamePadInput, + FirstPersonController +} = await fileImport(`${rootPath}/static/scripts/esm/character-controls.mjs`); + const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas')); window.focus(); @@ -23,7 +30,6 @@ const gfxOptions = { const assets = { map: new pc.Asset('map', 'container', { url: `${rootPath}/static/assets/models/fps-map.glb` }), - script: new pc.Asset('script', 'script', { url: `${rootPath}/static/scripts/camera/first-person-camera.js` }), helipad: new pc.Asset( 'helipad-env-atlas', 'texture', @@ -112,15 +118,15 @@ function createCharacterController(camera) { restitution: 0 }); entity.addComponent('script'); - entity.script.create('characterController', { + entity.script.create(FirstPersonController, { attributes: { - camera: camera, + camera, jumpForce: 850 } }); - entity.script.create('desktopInput'); - entity.script.create('mobileInput'); - entity.script.create('gamePadInput'); + entity.script.create(DesktopInput); + entity.script.create(MobileInput); + entity.script.create(GamePadInput); return entity; } diff --git a/scripts/camera/first-person-camera.js b/scripts/camera/first-person-camera.js deleted file mode 100644 index cd8d20d6fa5..00000000000 --- a/scripts/camera/first-person-camera.js +++ /dev/null @@ -1,905 +0,0 @@ -(() => { - const { createScript, math, Vec2, Vec3, Mat4 } = pc; - - const LOOK_MAX_ANGLE = 90; - - const tmpV1 = new Vec3(); - const tmpV2 = new Vec3(); - const tmpM1 = new Mat4(); - - /** - * Utility function for both touch and gamepad handling of deadzones. Takes a 2-axis joystick - * position in the range -1 to 1 and applies an upper and lower radial deadzone, remapping values in - * the legal range from 0 to 1. - * - * @param {Vec2} pos - The joystick position. - * @param {Vec2} remappedPos - The remapped joystick position. - * @param {number} deadZoneLow - The lower dead zone. - * @param {number} deadZoneHigh - The upper dead zone. - */ - function applyRadialDeadZone(pos, remappedPos, deadZoneLow, deadZoneHigh) { - const magnitude = pos.length(); - - if (magnitude > deadZoneLow) { - const legalRange = 1 - deadZoneHigh - deadZoneLow; - const normalizedMag = Math.min(1, (magnitude - deadZoneLow) / legalRange); - remappedPos.copy(pos).scale(normalizedMag / magnitude); - } else { - remappedPos.set(0, 0); - } - } - - class DesktopInput { - /** - * @type {HTMLCanvasElement} - * @private - */ - _canvas; - - /** - * @type {boolean} - * @private - */ - _enabled = true; - - /** - * @type {AppBase} - */ - app; - - /** - * @param {AppBase} app - The application. - */ - constructor(app) { - this.app = app; - this._canvas = app.graphicsDevice.canvas; - - this._onKeyDown = this._onKeyDown.bind(this); - this._onKeyUp = this._onKeyUp.bind(this); - this._onMouseDown = this._onMouseDown.bind(this); - this._onMouseMove = this._onMouseMove.bind(this); - - this.enabled = true; - } - - set enabled(val) { - this._enabled = val; - - if (val) { - window.addEventListener('keydown', this._onKeyDown); - window.addEventListener('keyup', this._onKeyUp); - window.addEventListener('mousedown', this._onMouseDown); - window.addEventListener('mousemove', this._onMouseMove); - } else { - window.removeEventListener('keydown', this._onKeyDown); - window.removeEventListener('keyup', this._onKeyUp); - window.removeEventListener('mousedown', this._onMouseDown); - window.removeEventListener('mousemove', this._onMouseMove); - } - } - - get enabled() { - return this._enabled; - } - - /** - * @param {string} key - The key pressed. - * @param {number} val - The key value. - * @private - */ - _handleKey(key, val) { - switch (key.toLowerCase()) { - case 'w': - case 'arrowup': - this.app.fire('cc:move:forward', val); - break; - case 's': - case 'arrowdown': - this.app.fire('cc:move:backward', val); - break; - case 'a': - case 'arrowleft': - this.app.fire('cc:move:left', val); - break; - case 'd': - case 'arrowright': - this.app.fire('cc:move:right', val); - break; - case ' ': - this.app.fire('cc:jump', !!val); - break; - case 'shift': - this.app.fire('cc:sprint', !!val); - break; - } - } - - /** - * @param {KeyboardEvent} e - The keyboard event. - * @private - */ - _onKeyDown(e) { - if (document.pointerLockElement !== this._canvas) { - return; - } - - if (e.repeat) { - return; - } - this._handleKey(e.key, 1); - } - - /** - * @param {KeyboardEvent} e - The keyboard event. - * @private - */ - _onKeyUp(e) { - if (e.repeat) { - return; - } - this._handleKey(e.key, 0); - } - - _onMouseDown(e) { - if (document.pointerLockElement !== this._canvas) { - this._canvas.requestPointerLock(); - } - } - - /** - * @param {MouseEvent} e - The mouse event. - * @private - */ - _onMouseMove(e) { - if (document.pointerLockElement !== this._canvas) { - return; - } - - const movementX = e.movementX || e.mozMovementX || e.webkitMovementX || 0; - const movementY = e.movementY || e.mozMovementY || e.webkitMovementY || 0; - - this.app.fire('cc:look', movementX, movementY); - } - - destroy() { - this.enabled = false; - } - } - - class MobileInput { - /** - * @type {GraphicsDevice} - * @private - */ - _device; - - /** - * @type {HTMLCanvasElement} - * @private - */ - _canvas; - - /** - * @type {boolean} - * @private - */ - _enabled = true; - - /** - * @type {number} - * @private - */ - _lastRightTap = 0; - - /** - * @type {number} - * @private - */ - _jumpTimeout; - - /** - * @type {Vec2} - * @private - */ - _remappedPos = new Vec2(); - - /** - * @type {{ identifier: number, center: Vec2; pos: Vec2 }} - * @private - */ - _leftStick = { - identifier: -1, - center: new Vec2(), - pos: new Vec2() - }; - - /** - * @type {{ identifier: number, center: Vec2; pos: Vec2 }} - * @private - */ - _rightStick = { - identifier: -1, - center: new Vec2(), - pos: new Vec2() - }; - - /** - * @type {AppBase} - */ - app; - - /** - * @type {number} - */ - deadZone = 0.3; - - /** - * @type {number} - */ - turnSpeed = 30; - - /** - * @type {number} - */ - radius = 50; - - /** - * @type {number} - */ - _doubleTapInterval = 300; - - /** - * @param {AppBase} app - The application. - */ - constructor(app) { - this.app = app; - this._device = app.graphicsDevice; - this._canvas = app.graphicsDevice.canvas; - - this._onTouchStart = this._onTouchStart.bind(this); - this._onTouchMove = this._onTouchMove.bind(this); - this._onTouchEnd = this._onTouchEnd.bind(this); - - this.enabled = true; - } - - set enabled(val) { - this._enabled = val; - if (val) { - this._canvas.addEventListener('touchstart', this._onTouchStart, false); - this._canvas.addEventListener('touchmove', this._onTouchMove, false); - this._canvas.addEventListener('touchend', this._onTouchEnd, false); - } else { - this._canvas.removeEventListener('touchstart', this._onTouchStart, false); - this._canvas.removeEventListener('touchmove', this._onTouchMove, false); - this._canvas.removeEventListener('touchend', this._onTouchEnd, false); - } - } - - get enabled() { - return this._enabled; - } - - /** - * @private - * @param {TouchEvent} e - The touch event. - */ - _onTouchStart(e) { - e.preventDefault(); - - const xFactor = this._device.width / this._canvas.clientWidth; - const yFactor = this._device.height / this._canvas.clientHeight; - - const touches = e.changedTouches; - for (let i = 0; i < touches.length; i++) { - const touch = touches[i]; - - if (touch.pageX <= this._canvas.clientWidth / 2 && this._leftStick.identifier === -1) { - // If the user touches the left half of the screen, create a left virtual joystick... - this._leftStick.identifier = touch.identifier; - this._leftStick.center.set(touch.pageX, touch.pageY); - this._leftStick.pos.set(0, 0); - this.app.fire('leftjoystick:enable', touch.pageX * xFactor, touch.pageY * yFactor); - } else if (touch.pageX > this._canvas.clientWidth / 2 && this._rightStick.identifier === -1) { - // ...otherwise create a right virtual joystick - this._rightStick.identifier = touch.identifier; - this._rightStick.center.set(touch.pageX, touch.pageY); - this._rightStick.pos.set(0, 0); - this.app.fire('rightjoystick:enable', touch.pageX * xFactor, touch.pageY * yFactor); - - // See how long since the last tap of the right virtual joystick to detect a double tap (jump) - const now = Date.now(); - if (now - this._lastRightTap < this._doubleTapInterval) { - if (this._jumpTimeout) { - clearTimeout(this._jumpTimeout); - } - this.app.fire('cc:jump', true); - this._jumpTimeout = setTimeout(() => this.app.fire('cc:jump', false), 50); - } - this._lastRightTap = now; - } - } - } - - /** - * @private - * @param {TouchEvent} e - The touch event. - */ - _onTouchMove(e) { - e.preventDefault(); - - const xFactor = this._device.width / this._canvas.clientWidth; - const yFactor = this._device.height / this._canvas.clientHeight; - - const touches = e.changedTouches; - for (let i = 0; i < touches.length; i++) { - const touch = touches[i]; - - // Update the current positions of the two virtual joysticks - if (touch.identifier === this._leftStick.identifier) { - this._leftStick.pos.set(touch.pageX, touch.pageY); - this._leftStick.pos.sub(this._leftStick.center); - this._leftStick.pos.scale(1 / this.radius); - this.app.fire('leftjoystick:move', touch.pageX * xFactor, touch.pageY * yFactor); - } else if (touch.identifier === this._rightStick.identifier) { - this._rightStick.pos.set(touch.pageX, touch.pageY); - this._rightStick.pos.sub(this._rightStick.center); - this._rightStick.pos.scale(1 / this.radius); - this.app.fire('rightjoystick:move', touch.pageX * xFactor, touch.pageY * yFactor); - } - } - } - - /** - * @private - * @param {TouchEvent} e - The touch event. - */ - _onTouchEnd(e) { - e.preventDefault(); - - var touches = e.changedTouches; - for (var i = 0; i < touches.length; i++) { - var touch = touches[i]; - - // If this touch is one of the sticks, get rid of it... - if (touch.identifier === this._leftStick.identifier) { - this._leftStick.identifier = -1; - this.app.fire('cc:move:forward', 0); - this.app.fire('cc:move:backward', 0); - this.app.fire('cc:move:left', 0); - this.app.fire('cc:move:right', 0); - this.app.fire('leftjoystick:disable'); - } else if (touch.identifier === this._rightStick.identifier) { - this._rightStick.identifier = -1; - this.app.fire('rightjoystick:disable'); - } - } - } - - /** - * @param {number} dt - The delta time. - */ - update(dt) { - // Moving - if (this._leftStick.identifier !== -1) { - // Apply a lower radial dead zone. We don't need an upper zone like with a real joypad - applyRadialDeadZone(this._leftStick.pos, this._remappedPos, this.deadZone, 0); - - const forward = -this._remappedPos.y; - if (this._lastForward !== forward) { - if (forward > 0) { - this.app.fire('cc:move:forward', Math.abs(forward)); - this.app.fire('cc:move:backward', 0); - } - if (forward < 0) { - this.app.fire('cc:move:forward', 0); - this.app.fire('cc:move:backward', Math.abs(forward)); - } - if (forward === 0) { - this.app.fire('cc:move:forward', 0); - this.app.fire('cc:move:backward', 0); - } - this._lastForward = forward; - } - - const strafe = this._remappedPos.x; - if (this._lastStrafe !== strafe) { - if (strafe > 0) { - this.app.fire('cc:move:left', 0); - this.app.fire('cc:move:right', Math.abs(strafe)); - } - if (strafe < 0) { - this.app.fire('cc:move:left', Math.abs(strafe)); - this.app.fire('cc:move:right', 0); - } - if (strafe === 0) { - this.app.fire('cc:move:left', 0); - this.app.fire('cc:move:right', 0); - } - this._lastStrafe = strafe; - } - } - - // Looking - if (this._rightStick.identifier !== -1) { - // Apply a lower radial dead zone. We don't need an upper zone like with a real joypad - applyRadialDeadZone(this._rightStick.pos, this._remappedPos, this.deadZone, 0); - - const movX = this._remappedPos.x * this.turnSpeed; - const movY = this._remappedPos.y * this.turnSpeed; - this.app.fire('cc:look', movX, movY); - } - } - - destroy() { - this.enabled = false; - } - } - - class GamePadInput { - /** - * @type {number} - * @private - */ - _jumpTimeout; - - /** - * @type {number} - * @private - */ - _lastForward = 0; - - /** - * @type {number} - * @private - */ - _lastStrafe = 0; - - /** - * @type {boolean} - * @private - */ - _lastJump = false; - - /** - * @type {Vec2} - * @private - */ - _remappedPos = new Vec2(); - - /** - * @type {{ center: Vec2; pos: Vec2 }} - * @private - */ - _leftStick = { - center: new Vec2(), - pos: new Vec2() - }; - - /** - * @type {{ center: Vec2; pos: Vec2 }} - * @private - */ - _rightStick = { - center: new Vec2(), - pos: new Vec2() - }; - - /** - * @type {AppBase} - */ - app; - - /** - * @type {number} - */ - deadZoneLow = 0.1; - - /** - * @type {number} - */ - deadZoneHigh = 0.1; - - /** - * @type {number} - */ - turnSpeed = 30; - - /** - * @param {AppBase} app - The application. - */ - constructor(app) { - this.app = app; - } - - /** - * @param {number} dt - The delta time. - */ - update(dt) { - const gamepads = navigator.getGamepads ? navigator.getGamepads() : []; - - for (let i = 0; i < gamepads.length; i++) { - const gamepad = gamepads[i]; - - // Only proceed if we have at least 2 sticks - if (gamepad && gamepad.mapping === 'standard' && gamepad.axes.length >= 4) { - // Moving (left stick) - this._leftStick.pos.set(gamepad.axes[0], gamepad.axes[1]); - applyRadialDeadZone(this._leftStick.pos, this._remappedPos, this.deadZoneLow, this.deadZoneHigh); - - const forward = -this._remappedPos.y; - if (this._lastForward !== forward) { - if (forward > 0) { - this.app.fire('cc:move:forward', Math.abs(forward)); - this.app.fire('cc:move:backward', 0); - } - if (forward < 0) { - this.app.fire('cc:move:forward', 0); - this.app.fire('cc:move:backward', Math.abs(forward)); - } - if (forward === 0) { - this.app.fire('cc:move:forward', 0); - this.app.fire('cc:move:backward', 0); - } - this._lastForward = forward; - } - - const strafe = this._remappedPos.x; - if (this._lastStrafe !== strafe) { - if (strafe > 0) { - this.app.fire('cc:move:left', 0); - this.app.fire('cc:move:right', Math.abs(strafe)); - } - if (strafe < 0) { - this.app.fire('cc:move:left', Math.abs(strafe)); - this.app.fire('cc:move:right', 0); - } - if (strafe === 0) { - this.app.fire('cc:move:left', 0); - this.app.fire('cc:move:right', 0); - } - this._lastStrafe = strafe; - } - - // Looking (right stick) - this._rightStick.pos.set(gamepad.axes[2], gamepad.axes[3]); - applyRadialDeadZone(this._rightStick.pos, this._remappedPos, this.deadZoneLow, this.deadZoneHigh); - - const movX = this._remappedPos.x * this.turnSpeed; - const movY = this._remappedPos.y * this.turnSpeed; - this.app.fire('cc:look', movX, movY); - - // Jumping (bottom button of right cluster) - if (gamepad.buttons[0].pressed && !this._lastJump) { - if (this._jumpTimeout) { - clearTimeout(this._jumpTimeout); - } - this.app.fire('cc:jump', true); - this._jumpTimeout = setTimeout(() => this.app.fire('cc:jump', false), 50); - } - this._lastJump = gamepad.buttons[0].pressed; - } - } - } - - destroy() {} - } - - class CharacterController { - /** - * @type {Entity} - * @private - */ - _camera; - - /** - * @type {RigidBodyComponent} - * @private - */ - _rigidbody; - - /** - * @type {boolean} - * @private - */ - _jumping = false; - - /** - * @type {AppBase} - */ - app; - - /** - * @type {Entity} - */ - entity; - - /** - * @type {Vec2} - */ - look = new Vec2(); - - /** - * @type {Record} - */ - controls = { - forward: 0, - backward: 0, - left: 0, - right: 0, - jump: false, - sprint: false - }; - - /** - * @type {number} - */ - lookSens = 0.08; - - /** - * @type {number} - */ - speedGround = 50; - - /** - * @type {number} - */ - speedAir = 5; - - /** - * @type {number} - */ - sprintMult = 1.5; - - /** - * @type {number} - */ - velocityDampingGround = 0.99; - - /** - * @type {number} - */ - velocityDampingAir = 0.99925; - - /** - * @type {number} - */ - jumpForce = 600; - - /** - * @param {AppBase} app - The application. - * @param {Entity} camera - The camera entity. - * @param {Entity} entity - The controller entity. - */ - constructor(app, camera, entity) { - this.app = app; - this.entity = entity; - - if (!camera) { - throw new Error('No camera entity found'); - } - this._camera = camera; - if (!entity.rigidbody) { - throw new Error('No rigidbody component found'); - } - this._rigidbody = entity.rigidbody; - - this.app.on('cc:look', (movX, movY) => { - this.look.x = math.clamp(this.look.x - movY * this.lookSens, -LOOK_MAX_ANGLE, LOOK_MAX_ANGLE); - this.look.y -= movX * this.lookSens; - }); - this.app.on('cc:move:forward', (val) => { - this.controls.forward = val; - }); - this.app.on('cc:move:backward', (val) => { - this.controls.backward = val; - }); - this.app.on('cc:move:left', (val) => { - this.controls.left = val; - }); - this.app.on('cc:move:right', (val) => { - this.controls.right = val; - }); - this.app.on('cc:jump', (state) => { - this.controls.jump = state; - }); - this.app.on('cc:sprint', (state) => { - this.controls.sprint = state; - }); - } - - /** - * @private - */ - _checkIfGrounded() { - const start = this.entity.getPosition(); - const end = tmpV1.copy(start).add(Vec3.DOWN); - end.y -= 0.1; - this._grounded = !!this._rigidbody.system.raycastFirst(start, end); - } - - /** - * @private - */ - _jump() { - if (this._rigidbody.linearVelocity.y < 0) { - this._jumping = false; - } - if (this.controls.jump && !this._jumping && this._grounded) { - this._jumping = true; - this._rigidbody.applyImpulse(0, this.jumpForce, 0); - } - } - - /** - * @private - */ - _look() { - this._camera.setLocalEulerAngles(this.look.x, this.look.y, 0); - } - - /** - * @param {number} dt - The delta time. - */ - _move(dt) { - tmpM1.setFromAxisAngle(Vec3.UP, this.look.y); - const dir = tmpV1.set(0, 0, 0); - if (this.controls.forward) { - dir.add(tmpV2.set(0, 0, -this.controls.forward)); - } - if (this.controls.backward) { - dir.add(tmpV2.set(0, 0, this.controls.backward)); - } - if (this.controls.left) { - dir.add(tmpV2.set(-this.controls.left, 0, 0)); - } - if (this.controls.right) { - dir.add(tmpV2.set(this.controls.right, 0, 0)); - } - tmpM1.transformVector(dir, dir); - - let speed = this._grounded ? this.speedGround : this.speedAir; - if (this.controls.sprint) { - speed *= this.sprintMult; - } - - const accel = dir.mulScalar(speed * dt); - const velocity = this._rigidbody.linearVelocity.add(accel); - - const damping = this._grounded ? this.velocityDampingGround : this.velocityDampingAir; - const mult = Math.pow(damping, dt * 1e3); - velocity.x *= mult; - velocity.z *= mult; - - this._rigidbody.linearVelocity = velocity; - } - - /** - * @param {number} dt - The delta time. - */ - update(dt) { - this._checkIfGrounded(); - this._jump(); - this._look(); - this._move(dt); - } - } - - // SCRIPTS - - const DesktopInputScript = createScript('desktopInput'); - - DesktopInputScript.prototype.initialize = function () { - this.input = new DesktopInput(this.app); - this.on('enable', () => (this.input.enabled = true)); - this.on('disable', () => (this.input.enabled = false)); - this.on('destroy', () => this.input.destroy()); - }; - - const MobileInputScript = createScript('mobileInput'); - - MobileInputScript.attributes.add('deadZone', { - title: 'Dead Zone', - description: 'Radial thickness of inner dead zone of the virtual joysticks. This dead zone ensures the virtual joysticks report a value of 0 even if a touch deviates a small amount from the initial touch.', - type: 'number', - min: 0, - max: 0.4, - default: 0.3 - }); - MobileInputScript.attributes.add('turnSpeed', { - title: 'Turn Speed', - description: 'Maximum turn speed in degrees per second', - type: 'number', - default: 30 - }); - MobileInputScript.attributes.add('radius', { - title: 'Radius', - description: 'The radius of the virtual joystick in CSS pixels.', - type: 'number', - default: 50 - }); - MobileInputScript.attributes.add('_doubleTapInterval', { - title: 'Double Tap Interval', - description: 'The time in milliseconds between two taps of the right virtual joystick for a double tap to register. A double tap will trigger a cc:jump.', - type: 'number', - default: 300 - }); - - MobileInputScript.prototype.initialize = function () { - this.input = new MobileInput(this.app); - this.input.deadZone = this.deadZone; - this.input.turnSpeed = this.turnSpeed; - this.input.radius = this.radius; - this.input._doubleTapInterval = this._doubleTapInterval; - this.on('enable', () => (this.input.enabled = true)); - this.on('disable', () => (this.input.enabled = false)); - this.on('destroy', () => this.input.destroy()); - }; - - MobileInputScript.prototype.update = function (dt) { - this.input.update(dt); - }; - - const GamePadInputScript = createScript('gamePadInput'); - - GamePadInputScript.attributes.add('deadZoneLow', { - title: 'Low Dead Zone', - description: 'Radial thickness of inner dead zone of pad\'s joysticks. This dead zone ensures that all pads report a value of 0 for each joystick axis when untouched.', - type: 'number', - min: 0, - max: 0.4, - default: 0.1 - }); - GamePadInputScript.attributes.add('deadZoneHigh', { - title: 'High Dead Zone', - description: 'Radial thickness of outer dead zone of pad\'s joysticks. This dead zone ensures that all pads can reach the -1 and 1 limits of each joystick axis.', - type: 'number', - min: 0, - max: 0.4, - default: 0.1 - }); - GamePadInputScript.attributes.add('turnSpeed', { - title: 'Turn Speed', - description: 'Maximum turn speed in degrees per second', - type: 'number', - default: 30 - }); - - GamePadInputScript.prototype.initialize = function () { - this.input = new GamePadInput(this.app); - this.input.deadZoneLow = this.deadZoneLow; - this.input.deadZoneHigh = this.deadZoneHigh; - this.input.turnSpeed = this.turnSpeed; - this.on('destroy', () => this.input.destroy()); - }; - - GamePadInputScript.prototype.update = function (dt) { - this.input.update(dt); - }; - - const CharacterControllerScript = createScript('characterController'); - - CharacterControllerScript.attributes.add('camera', { type: 'entity' }); - CharacterControllerScript.attributes.add('lookSens', { type: 'number', default: 0.08 }); - CharacterControllerScript.attributes.add('speedGround', { type: 'number', default: 50 }); - CharacterControllerScript.attributes.add('speedAir', { type: 'number', default: 5 }); - CharacterControllerScript.attributes.add('sprintMult', { type: 'number', default: 1.5 }); - CharacterControllerScript.attributes.add('velocityDampingGround', { type: 'number', default: 0.99 }); - CharacterControllerScript.attributes.add('velocityDampingAir', { type: 'number', default: 0.99925 }); - CharacterControllerScript.attributes.add('jumpForce', { type: 'number', default: 600 }); - - CharacterControllerScript.prototype.initialize = function () { - this.controller = new CharacterController(this.app, this.camera, this.entity); - this.controller.lookSens = this.lookSens; - this.controller.speedGround = this.speedGround; - this.controller.speedAir = this.speedAir; - this.controller.sprintMult = this.sprintMult; - this.controller.velocityDampingGround = this.velocityDampingGround; - this.controller.velocityDampingAir = this.velocityDampingAir; - this.controller.jumpForce = this.jumpForce; - }; - - CharacterControllerScript.prototype.update = function (dt) { - this.controller.update(dt); - }; -})(); diff --git a/scripts/esm/character-controls.mjs b/scripts/esm/character-controls.mjs new file mode 100644 index 00000000000..dfccde1dc11 --- /dev/null +++ b/scripts/esm/character-controls.mjs @@ -0,0 +1,834 @@ +/* eslint-disable-next-line import/no-unresolved */ +import { math, Script, Vec2, Vec3, Mat4 } from 'playcanvas'; + +/** @import { GraphicsDevice, Entity, RigidBodyComponent } from 'playcanvas' */ + +const LOOK_MAX_ANGLE = 90; + +const tmpV1 = new Vec3(); +const tmpV2 = new Vec3(); +const tmpM1 = new Mat4(); + +/** + * Utility function for both touch and gamepad handling of deadzones. Takes a 2-axis joystick + * position in the range -1 to 1 and applies an upper and lower radial deadzone, remapping values in + * the legal range from 0 to 1. + * + * @param {Vec2} pos - The joystick position. + * @param {Vec2} remappedPos - The remapped joystick position. + * @param {number} deadZoneLow - The lower dead zone. + * @param {number} deadZoneHigh - The upper dead zone. + */ +const applyRadialDeadZone = (pos, remappedPos, deadZoneLow, deadZoneHigh) => { + const magnitude = pos.length(); + + if (magnitude > deadZoneLow) { + const legalRange = 1 - deadZoneHigh - deadZoneLow; + const normalizedMag = Math.min(1, (magnitude - deadZoneLow) / legalRange); + remappedPos.copy(pos).scale(normalizedMag / magnitude); + } else { + remappedPos.set(0, 0); + } +}; + +class DesktopInput extends Script { + /** + * @type {HTMLCanvasElement} + * @private + */ + _canvas; + + /** + * @param {object} args - The script arguments. + */ + constructor(args) { + super(args); + this._canvas = this.app.graphicsDevice.canvas; + + this._onKeyDown = this._onKeyDown.bind(this); + this._onKeyUp = this._onKeyUp.bind(this); + this._onMouseDown = this._onMouseDown.bind(this); + this._onMouseMove = this._onMouseMove.bind(this); + + this.on('enable', () => this._bind()); + this.on('disable', () => this._unbind()); + this._bind(); + } + + /** + * @private + */ + _bind() { + window.addEventListener('keydown', this._onKeyDown); + window.addEventListener('keyup', this._onKeyUp); + window.addEventListener('mousedown', this._onMouseDown); + window.addEventListener('mousemove', this._onMouseMove); + } + + /** + * @private + */ + _unbind() { + window.removeEventListener('keydown', this._onKeyDown); + window.removeEventListener('keyup', this._onKeyUp); + window.removeEventListener('mousedown', this._onMouseDown); + window.removeEventListener('mousemove', this._onMouseMove); + } + + /** + * @param {string} key - The key pressed. + * @param {number} val - The key value. + * @private + */ + _handleKey(key, val) { + switch (key.toLowerCase()) { + case 'w': + case 'arrowup': + this.app.fire('cc:move:forward', val); + break; + case 's': + case 'arrowdown': + this.app.fire('cc:move:backward', val); + break; + case 'a': + case 'arrowleft': + this.app.fire('cc:move:left', val); + break; + case 'd': + case 'arrowright': + this.app.fire('cc:move:right', val); + break; + case ' ': + this.app.fire('cc:jump', !!val); + break; + case 'shift': + this.app.fire('cc:sprint', !!val); + break; + } + } + + /** + * @param {KeyboardEvent} e - The keyboard event. + * @private + */ + _onKeyDown(e) { + if (document.pointerLockElement !== this._canvas) { + return; + } + + if (e.repeat) { + return; + } + this._handleKey(e.key, 1); + } + + /** + * @param {KeyboardEvent} e - The keyboard event. + * @private + */ + _onKeyUp(e) { + if (e.repeat) { + return; + } + this._handleKey(e.key, 0); + } + + _onMouseDown(e) { + if (document.pointerLockElement !== this._canvas) { + this._canvas.requestPointerLock(); + } + } + + /** + * @param {MouseEvent} e - The mouse event. + * @private + */ + _onMouseMove(e) { + if (document.pointerLockElement !== this._canvas) { + return; + } + + const movementX = e.movementX || 0; + const movementY = e.movementY || 0; + + this.app.fire('cc:look', movementX, movementY); + } + + destroy() { + this._unbind(); + } +} + +class MobileInput extends Script { + /** + * @type {GraphicsDevice} + * @private + */ + _device; + + /** + * @type {HTMLCanvasElement} + * @private + */ + _canvas; + + /** + * @type {number} + * @private + */ + _lastRightTap = 0; + + /** + * @type {ReturnType | null} + * @private + */ + _jumpTimeout = null; + + /** + * @type {Vec2} + * @private + */ + _remappedPos = new Vec2(); + + /** + * @type {{ identifier: number, center: Vec2; pos: Vec2 }} + * @private + */ + _leftStick = { + identifier: -1, + center: new Vec2(), + pos: new Vec2() + }; + + /** + * @type {{ identifier: number, center: Vec2; pos: Vec2 }} + * @private + */ + _rightStick = { + identifier: -1, + center: new Vec2(), + pos: new Vec2() + }; + + /** + * @attribute + * @title Dead Zone + * @description Radial thickness of inner dead zone of the virtual joysticks. This dead zone ensures the virtual joysticks report a value of 0 even if a touch deviates a small amount from the initial touch. + * @type {number} + * @range [0, 0.4] + */ + deadZone = 0.3; + + /** + * @attribute + * @title Turn Speed + * @description Maximum turn speed in degrees per second + * @type {number} + */ + turnSpeed = 30; + + /** + * @attribute + * @title Radius + * @description The radius of the virtual joystick in CSS pixels. + * @type {number} + */ + radius = 50; + + /** + * @attribute + * @title Double Tap Interval + * @description The time in milliseconds between two taps of the right virtual joystick for a double tap to register. A double tap will trigger a cc:jump. + * @type {number} + */ + doubleTapInterval = 300; + + /** + * @param {object} args - The script arguments. + */ + constructor(args) { + super(args); + const { + deadZone, + turnSpeed, + radius, + doubleTapInterval + } = args.attributes; + + this.deadZone = deadZone ?? this.deadZone; + this.turnSpeed = turnSpeed ?? this.turnSpeed; + this.radius = radius ?? this.radius; + this.doubleTapInterval = doubleTapInterval ?? this.doubleTapInterval; + + this._device = this.app.graphicsDevice; + this._canvas = this._device.canvas; + + this._onTouchStart = this._onTouchStart.bind(this); + this._onTouchMove = this._onTouchMove.bind(this); + this._onTouchEnd = this._onTouchEnd.bind(this); + + this.on('enable', () => this._bind()); + this.on('disable', () => this._unbind()); + this._bind(); + } + + /** + * @private + */ + _bind() { + this._canvas.addEventListener('touchstart', this._onTouchStart, false); + this._canvas.addEventListener('touchmove', this._onTouchMove, false); + this._canvas.addEventListener('touchend', this._onTouchEnd, false); + } + + /** + * @private + */ + _unbind() { + this._canvas.removeEventListener('touchstart', this._onTouchStart, false); + this._canvas.removeEventListener('touchmove', this._onTouchMove, false); + this._canvas.removeEventListener('touchend', this._onTouchEnd, false); + } + + /** + * @private + * @param {TouchEvent} e - The touch event. + */ + _onTouchStart(e) { + e.preventDefault(); + + const xFactor = this._device.width / this._canvas.clientWidth; + const yFactor = this._device.height / this._canvas.clientHeight; + + const touches = e.changedTouches; + for (let i = 0; i < touches.length; i++) { + const touch = touches[i]; + + if (touch.pageX <= this._canvas.clientWidth / 2 && this._leftStick.identifier === -1) { + // If the user touches the left half of the screen, create a left virtual joystick... + this._leftStick.identifier = touch.identifier; + this._leftStick.center.set(touch.pageX, touch.pageY); + this._leftStick.pos.set(0, 0); + this.app.fire('leftjoystick:enable', touch.pageX * xFactor, touch.pageY * yFactor); + } else if (touch.pageX > this._canvas.clientWidth / 2 && this._rightStick.identifier === -1) { + // ...otherwise create a right virtual joystick + this._rightStick.identifier = touch.identifier; + this._rightStick.center.set(touch.pageX, touch.pageY); + this._rightStick.pos.set(0, 0); + this.app.fire('rightjoystick:enable', touch.pageX * xFactor, touch.pageY * yFactor); + + // See how long since the last tap of the right virtual joystick to detect a double tap (jump) + const now = Date.now(); + if (now - this._lastRightTap < this.doubleTapInterval) { + if (this._jumpTimeout) { + clearTimeout(this._jumpTimeout); + } + this.app.fire('cc:jump', true); + this._jumpTimeout = setTimeout(() => this.app.fire('cc:jump', false), 50); + } + this._lastRightTap = now; + } + } + } + + /** + * @private + * @param {TouchEvent} e - The touch event. + */ + _onTouchMove(e) { + e.preventDefault(); + + const xFactor = this._device.width / this._canvas.clientWidth; + const yFactor = this._device.height / this._canvas.clientHeight; + + const touches = e.changedTouches; + for (let i = 0; i < touches.length; i++) { + const touch = touches[i]; + + // Update the current positions of the two virtual joysticks + if (touch.identifier === this._leftStick.identifier) { + this._leftStick.pos.set(touch.pageX, touch.pageY); + this._leftStick.pos.sub(this._leftStick.center); + this._leftStick.pos.scale(1 / this.radius); + this.app.fire('leftjoystick:move', touch.pageX * xFactor, touch.pageY * yFactor); + } else if (touch.identifier === this._rightStick.identifier) { + this._rightStick.pos.set(touch.pageX, touch.pageY); + this._rightStick.pos.sub(this._rightStick.center); + this._rightStick.pos.scale(1 / this.radius); + this.app.fire('rightjoystick:move', touch.pageX * xFactor, touch.pageY * yFactor); + } + } + } + + /** + * @private + * @param {TouchEvent} e - The touch event. + */ + _onTouchEnd(e) { + e.preventDefault(); + + const touches = e.changedTouches; + for (let i = 0; i < touches.length; i++) { + const touch = touches[i]; + + // If this touch is one of the sticks, get rid of it... + if (touch.identifier === this._leftStick.identifier) { + this._leftStick.identifier = -1; + this.app.fire('cc:move:forward', 0); + this.app.fire('cc:move:backward', 0); + this.app.fire('cc:move:left', 0); + this.app.fire('cc:move:right', 0); + this.app.fire('leftjoystick:disable'); + } else if (touch.identifier === this._rightStick.identifier) { + this._rightStick.identifier = -1; + this.app.fire('rightjoystick:disable'); + } + } + } + + update() { + // Moving + if (this._leftStick.identifier !== -1) { + // Apply a lower radial dead zone. We don't need an upper zone like with a real joypad + applyRadialDeadZone(this._leftStick.pos, this._remappedPos, this.deadZone, 0); + + const forward = -this._remappedPos.y; + if (this._lastForward !== forward) { + if (forward > 0) { + this.app.fire('cc:move:forward', Math.abs(forward)); + this.app.fire('cc:move:backward', 0); + } + if (forward < 0) { + this.app.fire('cc:move:forward', 0); + this.app.fire('cc:move:backward', Math.abs(forward)); + } + if (forward === 0) { + this.app.fire('cc:move:forward', 0); + this.app.fire('cc:move:backward', 0); + } + this._lastForward = forward; + } + + const strafe = this._remappedPos.x; + if (this._lastStrafe !== strafe) { + if (strafe > 0) { + this.app.fire('cc:move:left', 0); + this.app.fire('cc:move:right', Math.abs(strafe)); + } + if (strafe < 0) { + this.app.fire('cc:move:left', Math.abs(strafe)); + this.app.fire('cc:move:right', 0); + } + if (strafe === 0) { + this.app.fire('cc:move:left', 0); + this.app.fire('cc:move:right', 0); + } + this._lastStrafe = strafe; + } + } + + // Looking + if (this._rightStick.identifier !== -1) { + // Apply a lower radial dead zone. We don't need an upper zone like with a real joypad + applyRadialDeadZone(this._rightStick.pos, this._remappedPos, this.deadZone, 0); + + const movX = this._remappedPos.x * this.turnSpeed; + const movY = this._remappedPos.y * this.turnSpeed; + this.app.fire('cc:look', movX, movY); + } + } + + destroy() { + this._unbind(); + } +} + +class GamePadInput extends Script { + /** + * @type {ReturnType | null} + * @private + */ + _jumpTimeout = null; + + /** + * @type {number} + * @private + */ + _lastForward = 0; + + /** + * @type {number} + * @private + */ + _lastStrafe = 0; + + /** + * @type {boolean} + * @private + */ + _lastJump = false; + + /** + * @type {Vec2} + * @private + */ + _remappedPos = new Vec2(); + + /** + * @type {{ center: Vec2; pos: Vec2 }} + * @private + */ + _leftStick = { + center: new Vec2(), + pos: new Vec2() + }; + + /** + * @type {{ center: Vec2; pos: Vec2 }} + * @private + */ + _rightStick = { + center: new Vec2(), + pos: new Vec2() + }; + + /** + * @attribute + * @title Dead Zone Low + * @description Radial thickness of inner dead zone of pad's joysticks. This dead zone ensures that all pads report a value of 0 for each joystick axis when untouched. + * @type {number} + * @range [0, 0.4] + */ + deadZoneLow = 0.1; + + /** + * @attribute + * @title Dead Zone High + * @description Radial thickness of outer dead zone of pad's joysticks. This dead zone ensures that all pads can reach the -1 and 1 limits of each joystick axis. + * @type {number} + * @range [0, 0.4] + */ + deadZoneHigh = 0.1; + + /** + * @attribute + * @title Turn Speed + * @description Maximum turn speed in degrees per second + * @type {number} + */ + turnSpeed = 30; + + /** + * @param {object} args - The script arguments. + */ + constructor(args) { + super(args); + const { + deadZoneLow, + deadZoneHigh, + turnSpeed + } = args.attributes; + + this.deadZoneLow = deadZoneLow ?? this.deadZoneLow; + this.deadZoneHigh = deadZoneHigh ?? this.deadZoneHigh; + this.turnSpeed = turnSpeed ?? this.turnSpeed; + } + + update() { + const gamepads = navigator.getGamepads ? navigator.getGamepads() : []; + + for (let i = 0; i < gamepads.length; i++) { + const gamepad = gamepads[i]; + + // Only proceed if we have at least 2 sticks + if (gamepad && gamepad.mapping === 'standard' && gamepad.axes.length >= 4) { + // Moving (left stick) + this._leftStick.pos.set(gamepad.axes[0], gamepad.axes[1]); + applyRadialDeadZone(this._leftStick.pos, this._remappedPos, this.deadZoneLow, this.deadZoneHigh); + + const forward = -this._remappedPos.y; + if (this._lastForward !== forward) { + if (forward > 0) { + this.app.fire('cc:move:forward', Math.abs(forward)); + this.app.fire('cc:move:backward', 0); + } + if (forward < 0) { + this.app.fire('cc:move:forward', 0); + this.app.fire('cc:move:backward', Math.abs(forward)); + } + if (forward === 0) { + this.app.fire('cc:move:forward', 0); + this.app.fire('cc:move:backward', 0); + } + this._lastForward = forward; + } + + const strafe = this._remappedPos.x; + if (this._lastStrafe !== strafe) { + if (strafe > 0) { + this.app.fire('cc:move:left', 0); + this.app.fire('cc:move:right', Math.abs(strafe)); + } + if (strafe < 0) { + this.app.fire('cc:move:left', Math.abs(strafe)); + this.app.fire('cc:move:right', 0); + } + if (strafe === 0) { + this.app.fire('cc:move:left', 0); + this.app.fire('cc:move:right', 0); + } + this._lastStrafe = strafe; + } + + // Looking (right stick) + this._rightStick.pos.set(gamepad.axes[2], gamepad.axes[3]); + applyRadialDeadZone(this._rightStick.pos, this._remappedPos, this.deadZoneLow, this.deadZoneHigh); + + const movX = this._remappedPos.x * this.turnSpeed; + const movY = this._remappedPos.y * this.turnSpeed; + this.app.fire('cc:look', movX, movY); + + // Jumping (bottom button of right cluster) + if (gamepad.buttons[0].pressed && !this._lastJump) { + if (this._jumpTimeout) { + clearTimeout(this._jumpTimeout); + } + this.app.fire('cc:jump', true); + this._jumpTimeout = setTimeout(() => this.app.fire('cc:jump', false), 50); + } + this._lastJump = gamepad.buttons[0].pressed; + } + } + } +} + +class FirstPersonController extends Script { + /** + * @type {RigidBodyComponent} + * @private + */ + _rigidbody; + + /** + * @type {boolean} + * @private + */ + _jumping = false; + + /** + * @type {Vec2} + */ + look = new Vec2(); + + /** + * @type {Record} + */ + controls = { + forward: 0, + backward: 0, + left: 0, + right: 0, + jump: false, + sprint: false + }; + + /** + * @attribute + * @type {Entity} + */ + camera; + + /** + * @attribute + * @type {number} + */ + lookSens = 0.08; + + /** + * @attribute + * @title Ground Speed + * @description The speed of the character when on the ground. + * @type {number} + */ + speedGround = 50; + + /** + * @attribute + * @title Air Speed + * @description The speed of the character when in the air. + * @type {number} + */ + speedAir = 5; + + /** + * @attribute + * @title Sprint Multiplier + * @description The multiplier applied to the speed when sprinting. + * @type {number} + */ + sprintMult = 1.5; + + /** + * @attribute + * @title Velocity Damping Ground + * @description The damping applied to the velocity when on the ground. + * @type {number} + */ + velocityDampingGround = 0.99; + + /** + * @attribute + * @title Velocity Damping Air + * @description The damping applied to the velocity when in the air. + * @type {number} + */ + velocityDampingAir = 0.99925; + + /** + * @attribute + * @title Jump Force + * @description The force applied when jumping. + * @type {number} + */ + jumpForce = 600; + + /** + * @param {object} args - The script arguments. + */ + constructor(args) { + super(args); + const { + camera, + lookSens, + speedGround, + speedAir, + sprintMult, + velocityDampingGround, + velocityDampingAir, + jumpForce + } = args.attributes; + + this.camera = camera; + this.lookSens = lookSens ?? this.lookSens; + this.speedGround = speedGround ?? this.speedGround; + this.speedAir = speedAir ?? this.speedAir; + this.sprintMult = sprintMult ?? this.sprintMult; + this.velocityDampingGround = velocityDampingGround ?? this.velocityDampingGround; + this.velocityDampingAir = velocityDampingAir ?? this.velocityDampingAir; + this.jumpForce = jumpForce ?? this.jumpForce; + + if (!camera) { + throw new Error('No camera entity found'); + } + if (!this.entity.rigidbody) { + throw new Error('No rigidbody component found'); + } + this._rigidbody = this.entity.rigidbody; + + this.app.on('cc:look', (movX, movY) => { + this.look.x = math.clamp(this.look.x - movY * this.lookSens, -LOOK_MAX_ANGLE, LOOK_MAX_ANGLE); + this.look.y -= movX * this.lookSens; + }); + this.app.on('cc:move:forward', (val) => { + this.controls.forward = val; + }); + this.app.on('cc:move:backward', (val) => { + this.controls.backward = val; + }); + this.app.on('cc:move:left', (val) => { + this.controls.left = val; + }); + this.app.on('cc:move:right', (val) => { + this.controls.right = val; + }); + this.app.on('cc:jump', (state) => { + this.controls.jump = state; + }); + this.app.on('cc:sprint', (state) => { + this.controls.sprint = state; + }); + } + + /** + * @private + */ + _checkIfGrounded() { + const start = this.entity.getPosition(); + const end = tmpV1.copy(start).add(Vec3.DOWN); + end.y -= 0.1; + this._grounded = !!this._rigidbody.system.raycastFirst(start, end); + } + + /** + * @private + */ + _jump() { + if (this._rigidbody.linearVelocity.y < 0) { + this._jumping = false; + } + if (this.controls.jump && !this._jumping && this._grounded) { + this._jumping = true; + this._rigidbody.applyImpulse(0, this.jumpForce, 0); + } + } + + /** + * @private + */ + _look() { + this.camera.setLocalEulerAngles(this.look.x, this.look.y, 0); + } + + /** + * @param {number} dt - The delta time. + */ + _move(dt) { + tmpM1.setFromAxisAngle(Vec3.UP, this.look.y); + const dir = tmpV1.set(0, 0, 0); + if (this.controls.forward) { + dir.add(tmpV2.set(0, 0, -this.controls.forward)); + } + if (this.controls.backward) { + dir.add(tmpV2.set(0, 0, this.controls.backward)); + } + if (this.controls.left) { + dir.add(tmpV2.set(-this.controls.left, 0, 0)); + } + if (this.controls.right) { + dir.add(tmpV2.set(this.controls.right, 0, 0)); + } + tmpM1.transformVector(dir, dir); + + let speed = this._grounded ? this.speedGround : this.speedAir; + if (this.controls.sprint) { + speed *= this.sprintMult; + } + + const accel = dir.mulScalar(speed * dt); + const velocity = this._rigidbody.linearVelocity.add(accel); + + const damping = this._grounded ? this.velocityDampingGround : this.velocityDampingAir; + const mult = Math.pow(damping, dt * 1e3); + velocity.x *= mult; + velocity.z *= mult; + + this._rigidbody.linearVelocity = velocity; + } + + /** + * @param {number} dt - The delta time. + */ + update(dt) { + this._checkIfGrounded(); + this._jump(); + this._look(); + this._move(dt); + } +} + +export { + DesktopInput, + MobileInput, + GamePadInput, + FirstPersonController +};