UNPKG

playcanvas

Version:

PlayCanvas WebGL game engine

545 lines (465 loc) 14.7 kB
import { math, InputFrame, KeyboardMouseSource, DualGestureSource, GamepadSource, Quat, Script, Vec3 } from 'playcanvas'; /** @import { Entity, RigidBodyComponent, RigidBodyComponentSystem } from 'playcanvas' */ /** * @typedef {object} FirstPersonControllerState * @property {Vec3} axis - The movement axis. * @property {number[]} mouse - The mouse position. * @property {number} a - The 'A' button state. * @property {number} space - The space key state. * @property {number} shift - The shift key state. * @property {number} ctrl - The ctrl key state. */ const EPSILON = 0.0001; const v = new Vec3(); const forward = new Vec3(); const right = new Vec3(); const offset = new Vec3(); const rotation = new Quat(); const frame = new InputFrame({ move: [0, 0, 0], rotate: [0, 0, 0], jump: [0] }); /** * Calculate the damp rate. * * @param {number} damping - The damping. * @param {number} dt - The delta time. * @returns {number} - The lerp rate. */ export const damp = (damping, dt) => 1 - Math.pow(damping, dt * 1000); /** * @param {number[]} stick - The stick * @param {number} low - The low dead zone * @param {number} high - The high dead zone */ const applyDeadZone = (stick, low, high) => { const mag = Math.sqrt(stick[0] * stick[0] + stick[1] * stick[1]); if (mag < low) { stick.fill(0); return; } const scale = (mag - low) / (high - low); stick[0] *= scale / mag; stick[1] *= scale / mag; }; class FirstPersonController extends Script { static scriptName = 'firstPersonController'; /** * @type {boolean} * @private */ _ready = false; /** * @type {RigidBodyComponent} * @private */ // @ts-ignore _rigidbody; /** * @type {KeyboardMouseSource} * @private */ _desktopInput = new KeyboardMouseSource({ pointerLock: true }); /** * @type {DualGestureSource} * @private */ _mobileInput = new DualGestureSource(); /** * @type {GamepadSource} * @private */ _gamepadInput = new GamepadSource(); /** * @type {FirstPersonControllerState} * @private */ _state = { axis: new Vec3(), mouse: [0, 0, 0], a: 0, space: 0, shift: 0, ctrl: 0 }; /** * @type {Vec3} * @private */ _angles = new Vec3(); /** * @type {boolean} * @private */ _grounded = false; /** * @type {boolean} * @private */ _jumping = false; /** * @type {number} * @private */ _mobileDeadZone = 0.3; /** * @type {number} * @private */ _mobileTurnSpeed = 30; /** * @type {number} * @private */ _mobileRadius = 50; /** * @type {number} * @private */ _mobileDoubleTapInterval = 300; /** * @type {number} * @private */ _gamePadDeadZoneLow = 0.1; /** * @type {number} * @private */ _gamePadDeadZoneHigh = 0.1; /** * @type {number} * @private */ _gamePadTurnSpeed = 30; /** * @attribute * @title Camera * @description The camera entity that will be used for looking around. * @type {Entity} */ // @ts-ignore 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; /** * The joystick event name for the UI position for the base and stick elements. * The event name is appended with the side: ':left' or ':right'. * * @attribute * @title Joystick Base Event Name * @type {string} */ joystickEventName = 'joystick'; initialize() { if (!this.camera) { throw new Error('FirstPersonController: Camera entity is required.'); } // check collision and rigidbody 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 = /** @type {RigidBodyComponent} */ (this.entity.rigidbody); // attach input this._desktopInput.attach(this.app.graphicsDevice.canvas); this._mobileInput.attach(this.app.graphicsDevice.canvas); this._gamepadInput.attach(this.app.graphicsDevice.canvas); // expose ui events this._mobileInput.on('joystick:position:left', ([bx, by, sx, sy]) => { this.app.fire(`${this.joystickEventName}:left`, bx, by, sx, sy); }); this._mobileInput.on('joystick:position:right', ([bx, by, sx, sy]) => { this.app.fire(`${this.joystickEventName}:right`, bx, by, sx, sy); }); this.on('destroy', this.destroy, this); this._ready = true; } /** * @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] * @default 0.3 */ set mobileDeadZone(value) { this._mobileDeadZone = value ?? this._mobileDeadZone; } get mobileDeadZone() { return this._mobileDeadZone; } /** * @attribute * @title Mobile Turn Speed * @description Maximum turn speed in degrees per second * @type {number} * @default 30 */ set mobileTurnSpeed(value) { this._mobileTurnSpeed = value ?? this._mobileTurnSpeed; } get mobileTurnSpeed() { return this._mobileTurnSpeed; } /** * @attribute * @title Mobile Radius * @description The radius of the virtual joystick in CSS pixels. * @type {number} * @default 50 */ 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} * @default 300 */ 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] * @default 0.1 */ set gamePadDeadZoneLow(value) { this._gamePadDeadZoneLow = value ?? 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] * @default 0.1 */ set gamePadDeadZoneHigh(value) { this._gamePadDeadZoneHigh = value ?? this._gamePadDeadZoneHigh; } get gamePadDeadZoneHigh() { return this._gamePadDeadZoneHigh; } /** * @attribute * @title GamePad Turn Speed * @description Maximum turn speed in degrees per second * @type {number} * @default 30 */ set gamePadTurnSpeed(value) { this._gamePadTurnSpeed = value ?? this._gamePadTurnSpeed; } get gamePadTurnSpeed() { return this._gamePadTurnSpeed; } /** * @param {InputFrame<{ move: number[], rotate: number[], jump: number[] }>} frame - The input frame. * @param {number} dt - The delta time. * @private */ _updateController(frame, dt) { const { move, rotate, jump } = frame.read(); // jump if (this._rigidbody.linearVelocity.y < 0) { this._jumping = false; } if (jump[0] && !this._jumping && this._grounded) { this._jumping = true; this._rigidbody.applyImpulse(0, this.jumpForce, 0); } // rotate this._angles.add(v.set(-rotate[1], -rotate[0], 0)); this._angles.x = math.clamp(this._angles.x, -90, 90); this.camera.setLocalEulerAngles(this._angles); // move rotation.setFromEulerAngles(0, this._angles.y, 0); rotation.transformVector(Vec3.FORWARD, forward); rotation.transformVector(Vec3.RIGHT, right); offset.set(0, 0, 0); offset.add(forward.mulScalar(move[2])); offset.add(right.mulScalar(move[0])); const velocity = this._rigidbody.linearVelocity.add(offset); const alpha = damp(this._grounded ? this.velocityDampingGround : this.velocityDampingAir, dt); velocity.x = math.lerp(velocity.x, 0, alpha); velocity.z = math.lerp(velocity.z, 0, alpha); this._rigidbody.linearVelocity = velocity; } /** * @param {number} dt - The delta time. */ update(dt) { if (!this._ready) { return; } const { keyCode } = KeyboardMouseSource; const { buttonCode } = GamepadSource; const { key, button, mouse } = this._desktopInput.read(); const { leftInput, rightInput, doubleTap } = this._mobileInput.read(); const { buttons, leftStick, rightStick } = this._gamepadInput.read(); // apply dead zone to gamepad sticks applyDeadZone(leftStick, this.gamePadDeadZoneLow, this.gamePadDeadZoneHigh); applyDeadZone(rightStick, this.gamePadDeadZoneLow, this.gamePadDeadZoneHigh); // update state this._state.axis.add(v.set( (key[keyCode.D] - key[keyCode.A]) + (key[keyCode.RIGHT] - key[keyCode.LEFT]), (key[keyCode.E] - key[keyCode.Q]), (key[keyCode.W] - key[keyCode.S]) + (key[keyCode.UP] - key[keyCode.DOWN]) )); for (let i = 0; i < this._state.mouse.length; i++) { this._state.mouse[i] += button[i]; } this._state.a += buttons[buttonCode.A]; this._state.space += key[keyCode.SPACE]; this._state.shift += key[keyCode.SHIFT]; this._state.ctrl += key[keyCode.CTRL]; // check if grounded const start = this.entity.getPosition(); const end = v.copy(start).add(Vec3.DOWN); end.y -= 0.1; const system = /** @type {RigidBodyComponentSystem} */ (this._rigidbody.system); this._grounded = !!system.raycastFirst(start, end); const moveMult = (this._grounded ? this.speedGround : this.speedAir) * dt; const rotateMult = this.lookSens * 60 * dt; const rotateTouchMult = this._mobileTurnSpeed * dt; const rotateJoystickMult = this.gamePadTurnSpeed * dt; const { deltas } = frame; // desktop move v.set(0, 0, 0); const keyMove = this._state.axis.clone().normalize(); v.add(keyMove.mulScalar(moveMult * (this._state.shift ? this.sprintMult : 1))); deltas.move.append([v.x, v.y, v.z]); // desktop rotate v.set(0, 0, 0); const mouseRotate = new Vec3(mouse[0], mouse[1], 0); v.add(mouseRotate.mulScalar(rotateMult)); deltas.rotate.append([v.x, v.y, v.z]); // desktop jump deltas.jump.append([this._state.space]); // mobile move v.set(0, 0, 0); const flyMove = new Vec3(leftInput[0], 0, -leftInput[1]); flyMove.mulScalar(2); const mag = flyMove.length(); if (mag > 1) { flyMove.normalize(); } v.add(flyMove.mulScalar(moveMult * (mag > 2 - EPSILON ? this.sprintMult : 1))); deltas.move.append([v.x, v.y, v.z]); // mobile rotate v.set(0, 0, 0); const mobileRotate = new Vec3(rightInput[0], rightInput[1], 0); v.add(mobileRotate.mulScalar(rotateTouchMult)); deltas.rotate.append([v.x, v.y, v.z]); // mobile jump deltas.jump.append([doubleTap[0]]); // gamepad move v.set(0, 0, 0); const stickMove = new Vec3(leftStick[0], 0, -leftStick[1]); v.add(stickMove.mulScalar(moveMult)); deltas.move.append([v.x, v.y, v.z]); // gamepad rotate v.set(0, 0, 0); const stickRotate = new Vec3(rightStick[0], rightStick[1], 0); v.add(stickRotate.mulScalar(rotateJoystickMult)); deltas.rotate.append([v.x, v.y, v.z]); // gamepad jump deltas.jump.append([this._state.a]); // update controller this._updateController(frame, dt); } destroy() { this._desktopInput.destroy(); this._mobileInput.destroy(); this._gamepadInput.destroy(); } } export { FirstPersonController };