UNPKG

playcanvas

Version:

PlayCanvas WebGL game engine

1,096 lines (948 loc) 29.4 kB
import { math, Script, Vec2, Vec3, Mat4 } from 'playcanvas'; /** @import { AppBase, 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).mulScalar(normalizedMag / magnitude); } else { remappedPos.set(0, 0); } }; class KeyboardMouseInput { /** * @private * @type {AppBase} */ _app; /** * @type {HTMLCanvasElement} * @private */ _canvas; /** * @type {boolean} * @private */ _enabled = true; /** * @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._bind(); } set enabled(value) { if (value === this._enabled) { return; } this._enabled = value ?? this._enabled; if (this._enabled) { this._bind(); } else { this._unbind(); } } get enabled() { return this._enabled; } /** * @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); } /** * @param {MouseEvent} e - The mouse event. * @private */ _onMouseDown(e) { if (e.target === this._canvas && 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 { /** * @type {AppBase} * @private */ _app; /** * @type {GraphicsDevice} * @private */ _device; /** * @type {HTMLCanvasElement} * @private */ _canvas; /** * @type {number} * @private */ _lastRightTap = 0; /** * @type {ReturnType<typeof setTimeout> | null} * @private */ _jumpTimeout = null; /** * @type {number} * @private */ _lastForward = 0; /** * @type {number} * @private */ _lastStrafe = 0; /** * @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 {boolean} * @private */ _enabled = true; /** * @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 = 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._bind(); } set enabled(value) { if (value === this._enabled) { return; } this._enabled = value ?? this._enabled; if (this._enabled) { this._bind(); } else { this._unbind(); } } get enabled() { return this._enabled; } /** * @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.mulScalar(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.mulScalar(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() { if (!this.enabled) { return; } // 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 { /** * @type {AppBase} * @private */ _app; /** * @type {ReturnType<typeof setTimeout> | 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() }; /** * @type {boolean} * @private */ _enabled = true; /** * @type {number} */ deadZoneLow = 0.1; /** * @type {number} */ deadZoneHigh = 0.1; /** * @type {number} */ turnSpeed = 30; /** * @param {AppBase} app - The application. */ constructor(app) { this._app = app; } set enabled(value) { if (value === this._enabled) { return; } this._enabled = value ?? this._enabled; } get enabled() { return this._enabled; } update() { if (!this.enabled) { return; } 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() { this.enabled = false; } } class FirstPersonController extends Script { /** * @type {RigidBodyComponent} * @private */ _rigidbody; /** * @type {boolean} * @private */ _jumping = false; /** * @type {KeyboardMouseInput} * @private */ _keyboardMouseInput; /** * @type {MobileInput} * @private */ _mobileInput; /** * @type {number} * @private */ _mobileDeadZone = 0.3; /** * @type {number} * @private */ _mobileTurnSpeed = 30; /** * @type {number} * @private */ _mobileRadius = 50; /** * @type {number} * @private */ _mobileDoubleTapInterval = 300; /** * @type {GamePadInput} * @private */ _gamePadInput; /** * @type {number} * @private */ _gamePadDeadZoneLow = 0.1; /** * @type {number} * @private */ _gamePadDeadZoneHigh = 0.1; /** * @type {number} * @private */ _gamePadTurnSpeed = 30; /** * @type {Vec2} */ look = new Vec2(); /** * @type {Record<string, boolean | number>} */ controls = { forward: 0, backward: 0, left: 0, right: 0, jump: false, sprint: false }; /** * @attribute * @type {Entity} */ camera; /** * @attribute * @title Look Sensitivity * @description The sensitivity of the look controls. * @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; initialize() { // input this._keyboardMouseInput = new KeyboardMouseInput(this.app); this._mobileInput = new MobileInput(this.app); this._gamePadInput = new GamePadInput(this.app); this.on('enable', () => { this._keyboardMouseInput.enabled = true; this._mobileInput.enabled = true; this._gamePadInput.enabled = true; }); this.on('disable', () => { this._keyboardMouseInput.enabled = false; this._mobileInput.enabled = false; this._gamePadInput.enabled = false; }); if (!this.camera) { this.camera = this.entity.findComponent('camera').entity; if (!this.camera) { throw new Error('FirstPersonController expects a camera entity'); } } if (!this.entity.collision) { this.entity.addComponent('collision', { type: 'capsule', radius: 0.5, height: 2 }); } if (!this.entity.rigidbody) { this.entity.addComponent('rigidbody', { type: 'dynamic', mass: 100, linearDamping: 0, angularDamping: 0, linearFactor: Vec3.ONE, angularFactor: Vec3.ZERO, friction: 0.5, restitution: 0 }); } this._rigidbody = this.entity.rigidbody; this.mobileDeadZone = this._mobileDeadZone; this.mobileTurnSpeed = this._mobileTurnSpeed; this.gamePadDeadZoneLow = this._gamePadDeadZoneLow; this.gamePadDeadZoneHigh = this._gamePadDeadZoneHigh; this.gamePadTurnSpeed = this._gamePadTurnSpeed; 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; }); this.on('destroy', this.destroy, this); } /** * @attribute * @title Mobile 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] */ set mobileDeadZone(value) { this._mobileDeadZone = value ?? this._mobileDeadZone; if (this._mobileInput) { this._mobileInput.deadZone = this._mobileDeadZone; } } get mobileDeadZone() { return this._mobileDeadZone; } /** * @attribute * @title Mobile Turn Speed * @description Maximum turn speed in degrees per second * @type {number} */ set mobileTurnSpeed(value) { this._mobileTurnSpeed = value ?? this._mobileTurnSpeed; if (this._mobileInput) { this._mobileInput.turnSpeed = this._mobileTurnSpeed; } } get mobileTurnSpeed() { return this._mobileTurnSpeed; } /** * @attribute * @title Mobile Radius * @description The radius of the virtual joystick in CSS pixels. * @type {number} */ set mobileRadius(value) { this._mobileRadius = value ?? this._mobileRadius; } get mobileRadius() { return this._mobileRadius; } /** * @attribute * @title Mobile 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} */ set mobileDoubleTapInterval(value) { this._mobileDoubleTapInterval = value ?? this._mobileDoubleTapInterval; } get mobileDoubleTapInterval() { return this._mobileDoubleTapInterval; } /** * @attribute * @title GamePad 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] */ set gamePadDeadZoneLow(value) { this._gamePadDeadZoneLow = value ?? this._gamePadDeadZoneLow; if (this._gamePadInput) { this._gamePadInput.deadZoneLow = this._gamePadDeadZoneLow; } } get gamePadDeadZoneLow() { return this._gamePadDeadZoneLow; } /** * @attribute * @title GamePad 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] */ set gamePadDeadZoneHigh(value) { this._gamePadDeadZoneHigh = value ?? this._gamePadDeadZoneHigh; if (this._gamePadInput) { this._gamePadInput.deadZoneHigh = this._gamePadDeadZoneHigh; } } get gamePadDeadZoneHigh() { return this._gamePadDeadZoneHigh; } /** * @attribute * @title GamePad Turn Speed * @description Maximum turn speed in degrees per second * @type {number} */ set gamePadTurnSpeed(value) { this._gamePadTurnSpeed = value ?? this._gamePadTurnSpeed; if (this._gamePadInput) { this._gamePadInput.turnSpeed = this._gamePadTurnSpeed; } } get gamePadTurnSpeed() { return this._gamePadTurnSpeed; } /** * @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._mobileInput.update(); this._gamePadInput.update(); this._checkIfGrounded(); this._jump(); this._look(); this._move(dt); } destroy() { this._keyboardMouseInput.destroy(); this._mobileInput.destroy(); this._gamePadInput.destroy(); } } export { FirstPersonController };