UNPKG

playcanvas

Version:

Open-source WebGL/WebGPU 3D engine for the web

967 lines (844 loc) 29.1 kB
import { math, InputFrame, KeyboardMouseSource, DualGestureSource, GamepadSource, Quat, Script, Vec3 } from 'playcanvas'; /** @import { Entity, RigidBodyComponent, RigidBodyComponentSystem } from 'playcanvas' */ /** * @typedef {object} ThirdPersonControllerState * @property {Vec3} axis - The movement axis. * @property {number[]} mouse - The mouse delta. * @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; // scratch const v = new Vec3(); const v2 = new Vec3(); const v3 = new Vec3(); const v4 = new Vec3(); const forward = new Vec3(); const right = new Vec3(); const offset = new Vec3(); const tmpQ = 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 factor (smaller = snappier). * @param {number} dt - The delta time. * @returns {number} - The lerp factor in 0..1. */ 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; }; /** * Lerp between two angles taking the shortest arc. * * @param {number} from - Source angle in degrees. * @param {number} to - Target angle in degrees. * @param {number} alpha - Lerp factor in 0..1. * @returns {number} The interpolated angle in degrees. */ const lerpAngle = (from, to, alpha) => { const delta = (((to - from) % 360) + 540) % 360 - 180; return from + delta * alpha; }; /** * A reusable third-person character controller. * * The script is attached to a character controller entity (a dynamic rigidbody * with a capsule collision shape). It reads keyboard / mouse / touch / gamepad * input and: * * - Moves the character along the ground using a velocity-based capsule. * - Rotates an optional {@link characterModel} child entity to face the * movement direction. * - Orbits a separate {@link camera} entity around the character at a * configurable distance and elevation, with smooth follow and wall collision * avoidance via raycast. * - Fires script events that consumers can use to drive animations: * - `speed` (integer 0/1/2): idle / walk / jog buckets when the bucket changes * - `jump`: fired once each time the character jumps */ class ThirdPersonController extends Script { static scriptName = 'thirdPersonController'; /** * @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 {ThirdPersonControllerState} * @private */ _state = { axis: new Vec3(), mouse: [0, 0, 0], a: 0, space: 0, shift: 0, ctrl: 0 }; /** * Current camera yaw in degrees (around character). * * @type {number} * @private */ _yaw = 0; /** * Current camera pitch in degrees (around character). Positive looks down * at the character from above; the default starts the camera elevated and * angled down at the character from behind. * * @type {number} * @private */ _pitch = 35; /** * Current smoothed character model yaw in degrees. * * @type {number} * @private */ _modelYaw = 0; /** * Smoothed camera world position. * * @type {Vec3} * @private */ _camPos = new Vec3(); /** * Smoothed look-at point. * * @type {Vec3} * @private */ _camLookAt = new Vec3(); /** * @type {boolean} * @private */ _camInitialized = false; /** * @type {boolean} * @private */ _grounded = false; /** * @type {boolean} * @private */ _jumping = false; /** * @type {number} * @private */ _lastSpeedBucket = 0; /** * @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 is orbited around the character. Must * be a top-level entity (not parented to the character controller) so the * controller can position it freely each frame. * @type {Entity} */ // @ts-ignore camera; /** * @attribute * @title Character Model * @description Optional child entity that holds the visible character mesh. * If provided, the controller will smoothly rotate this entity around Y to * face the movement direction. * @type {Entity} */ // @ts-ignore characterModel; /** * @attribute * @title Look Sensitivity * @description The mouse sensitivity for orbiting the camera. * @type {number} */ lookSens = 0.15; /** * @attribute * @title Invert Look Y * @description When true, vertical mouse movement is inverted (mouse up * tilts the camera down and vice versa). * @type {boolean} */ invertLookY = false; /** * @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 vertical impulse applied when the character jumps. * @type {number} */ jumpForce = 600; /** * @attribute * @title Initial Pitch * @description Camera pitch (degrees) used on the first frame, clamped to * `[pitchMin, pitchMax]`. Positive values position the camera higher, * looking down at the character. * @type {number} */ initialPitch = 35; /** * @attribute * @title Pitch Min * @description Lowest pitch angle (degrees). Negative looks up at character * from below, positive looks down. * @type {number} */ pitchMin = -30; /** * @attribute * @title Pitch Max * @description Highest pitch angle (degrees, looking down at character). * @type {number} */ pitchMax = 75; /** * @attribute * @title Camera Distance * @description Distance from the character to the camera, in world units. * @type {number} */ cameraDistance = 5; /** * @attribute * @title Camera Distance Min * @description Closest the camera can zoom in to (mouse scroll wheel * controls zoom within `[cameraDistanceMin, cameraDistanceMax]`). * @type {number} */ cameraDistanceMin = 1.5; /** * @attribute * @title Camera Distance Max * @description Farthest the camera can zoom out to. * @type {number} */ cameraDistanceMax = 15; /** * @attribute * @title Zoom Speed * @description Mouse scroll wheel sensitivity. Larger values zoom faster * per notch of the wheel. * @type {number} */ zoomSpeed = 0.01; /** * @attribute * @title Zoom Damping * @description Damping factor used to smooth scroll-wheel zoom. This is * the fraction of "distance to target" retained per millisecond, so values * close to `1` are smooth/laggy and values close to `0` are snappy. `0` * disables smoothing entirely (instant zoom). * @range [0, 1] * @type {number} */ zoomDamping = 0.997; /** * Target camera distance the actual `cameraDistance` lerps toward each * frame; mutated by the scroll wheel. * * @type {number} * @private */ _targetCameraDistance = 5; /** * @attribute * @title Camera Height * @description Vertical offset of the camera target above the character * pivot, in world units. The camera looks at the character position raised * by this amount, and orbits around that point. * @type {number} */ cameraHeight = 1.2; /** * @attribute * @title Camera Min Height Above Character * @description Minimum Y offset (in world units) the camera is allowed to * sit relative to the character entity. The final camera Y is clamped to * `characterY + cameraMinHeightAboveCharacter`, so for the default value * of `0` the camera will never drop below the character's pivot Y. Use * positive values to keep the camera above head height, negative values * to allow it to dip toward the feet. * @type {number} */ cameraMinHeightAboveCharacter = 0; /** * @attribute * @title Camera Min Distance * @description When the camera collides with geometry, it is clamped no * closer than this distance from the character. * @type {number} */ cameraMinDistance = 0.4; /** * @attribute * @title Camera Collision Padding * @description Extra clearance applied to the camera position when a wall * is detected, to keep the camera from sitting flush against geometry. * @type {number} */ cameraCollisionPadding = 0.25; /** * @attribute * @title Camera Collision Damping * @description Damping factor used to smooth the camera's reaction to * walls coming between the camera and the character. This is the fraction * of "distance to target" retained per millisecond, so values close to `1` * are smooth/laggy and values close to `0` snap immediately. `0` disables * smoothing for the collision response. * @range [0, 1] * @type {number} */ cameraCollisionDamping = 0.99; /** * Smoothed effective camera distance (lerps toward the raycast-clamped * distance each frame). Initialised in `initialize()`. * * @type {number} * @private */ _clampedDistance = 5; /** * @attribute * @title Camera Position Damping * @description Damping factor for camera follow smoothing. Smaller values * produce a snappier camera; larger values produce a laggier camera. A * value of 0 disables smoothing entirely. * @range [0, 1] * @type {number} */ cameraPositionDamping = 0.0005; /** * @attribute * @title Camera Look-At Damping * @description Damping factor for the smoothed look-at point follow. * @range [0, 1] * @type {number} */ cameraLookAtDamping = 0.00001; /** * @attribute * @title Model Turn Smoothing * @description Damping factor for the character model's rotation toward the * movement direction. Smaller values produce a snappier turn. * @range [0, 1] * @type {number} */ modelTurnSmoothing = 0.000005; /** * @attribute * @title Model Yaw Offset * @description Yaw offset in degrees applied to the character model's * facing direction. Use this to correct for models whose forward axis is * not -Z (e.g. set to 180 for a model whose forward is +Z). * @type {number} */ modelYawOffset = 0; /** * @attribute * @title Walk Speed Threshold * @description Horizontal speed (units/sec) above which the controller * reports `speed` bucket 1 (walk) via the `speed` event. * @type {number} */ walkSpeedThreshold = 0.5; /** * @attribute * @title Jog Speed Threshold * @description Horizontal speed (units/sec) above which the controller * reports `speed` bucket 2 (jog/run) via the `speed` event. * @type {number} */ jogSpeedThreshold = 4; /** * 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('ThirdPersonController: Camera entity is required.'); } // collision and rigidbody defaults 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 for mobile virtual joysticks 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); }); // initial camera yaw from the camera entity, if it already faces character const camEuler = this.camera.getEulerAngles(); this._yaw = camEuler.y; this._pitch = math.clamp(this.initialPitch, this.pitchMin, this.pitchMax); this._targetCameraDistance = this.cameraDistance; this._clampedDistance = this.cameraDistance; // initial character model yaw - face away from the camera (so the // camera starts behind the character looking at its back). This is // computed from the camera yaw using the same atan2 formula that the // running update uses on velocity, so it produces a value that won't // jump when the character first starts moving forward. if (this.characterModel) { tmpQ.setFromEulerAngles(0, this._yaw, 0); tmpQ.transformVector(Vec3.FORWARD, forward); this._modelYaw = Math.atan2(forward.x, forward.z) * math.RAD_TO_DEG + this.modelYawOffset; this.characterModel.setLocalEulerAngles(0, this._modelYaw, 0); } 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. * @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 Milliseconds between two taps of the right virtual joystick * for a double-tap to register as a 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 Inner dead zone of pad joysticks. * @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 Outer dead zone of pad joysticks. * @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 gamepad 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); this.fire('jump'); } // camera orbit (mouse rotates camera around character; W/S becomes // forward/back along camera's flat yaw) this._yaw -= rotate[0]; this._pitch -= (this.invertLookY ? -1 : 1) * rotate[1]; this._pitch = math.clamp(this._pitch, this.pitchMin, this.pitchMax); // movement direction relative to camera yaw only tmpQ.setFromEulerAngles(0, this._yaw, 0); tmpQ.transformVector(Vec3.FORWARD, forward); tmpQ.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; // character model yaw: smoothly turn toward horizontal velocity const horizSpeed = Math.sqrt(velocity.x * velocity.x + velocity.z * velocity.z); if (this.characterModel) { if (horizSpeed > this.walkSpeedThreshold * 0.5) { // movement direction in world space; convert to yaw, plus a // per-model offset so models with a different forward axis can // be aligned without rotating the mesh data. const targetYaw = Math.atan2(velocity.x, velocity.z) * math.RAD_TO_DEG + this.modelYawOffset; const a = damp(this.modelTurnSmoothing, dt); this._modelYaw = lerpAngle(this._modelYaw, targetYaw, a); } this.characterModel.setLocalEulerAngles(0, this._modelYaw, 0); } // animation speed bucket let bucket = 0; if (horizSpeed >= this.jogSpeedThreshold) { bucket = 2; } else if (horizSpeed >= this.walkSpeedThreshold) { bucket = 1; } if (bucket !== this._lastSpeedBucket) { this._lastSpeedBucket = bucket; this.fire('speed', bucket); } // ---- camera positioning ---- const charPos = this.entity.getPosition(); // pivot = character + cameraHeight on Y; camera orbits this point const pivot = v.copy(charPos); pivot.y += this.cameraHeight; // smooth wheel zoom: lerp the actual cameraDistance toward the target const zoomA = damp(this.zoomDamping, dt); this.cameraDistance = math.lerp(this.cameraDistance, this._targetCameraDistance, zoomA); // desired camera offset from pivot using current yaw/pitch. // Negative pitch in the rotation so the convention is: positive pitch // raises the camera above the pivot looking down; negative pitch drops // the camera below the pivot looking up. const orbitQ = tmpQ.setFromEulerAngles(-this._pitch, this._yaw, 0); const back = orbitQ.transformVector(Vec3.BACK, v2); // wall collision: raycast from pivot to where the camera WOULD sit at // its full desired distance, then smoothly track the resulting clamped // distance so that the camera eases in/out behind walls rather than // snapping when geometry comes between the camera and the character. const sys = /** @type {RigidBodyComponentSystem} */ (this._rigidbody.system); const probePos = v3.copy(pivot).add(v4.copy(back).mulScalar(this.cameraDistance)); const hit = sys.raycastFirst(pivot, probePos); let targetClamped = this.cameraDistance; if (hit) { const hitDist = v4.copy(hit.point).sub(pivot).length(); const safeDist = Math.max(hitDist - this.cameraCollisionPadding, this.cameraMinDistance); targetClamped = Math.min(targetClamped, safeDist); } const collA = damp(this.cameraCollisionDamping, dt); this._clampedDistance = math.lerp(this._clampedDistance, targetClamped, collA); const finalPos = v3.copy(pivot).add(back.mulScalar(this._clampedDistance)); // clamp the camera Y so it never drops below a configurable offset // relative to the character (prevents the orbit from putting the // camera lower than the character itself when the player pitches down). const minY = charPos.y + this.cameraMinHeightAboveCharacter; if (finalPos.y < minY) { finalPos.y = minY; } if (!this._camInitialized) { this._camPos.copy(finalPos); this._camLookAt.copy(pivot); this._camInitialized = true; } else { const posA = damp(this.cameraPositionDamping, dt); this._camPos.lerp(this._camPos, finalPos, posA); const lookA = damp(this.cameraLookAtDamping, dt); this._camLookAt.lerp(this._camLookAt, pivot, lookA); } this.camera.setPosition(this._camPos); this.camera.lookAt(this._camLookAt); } /** * @param {number} dt - The delta time. */ update(dt) { if (!this._ready) { return; } const { keyCode } = KeyboardMouseSource; const { buttonCode } = GamepadSource; const { key, button, mouse, wheel } = this._desktopInput.read(); const { leftInput, rightInput, doubleTap } = this._mobileInput.read(); const { buttons, leftStick, rightStick } = this._gamepadInput.read(); applyDeadZone(leftStick, this.gamePadDeadZoneLow, this.gamePadDeadZoneHigh); applyDeadZone(rightStick, this.gamePadDeadZoneLow, this.gamePadDeadZoneHigh); // mouse scroll wheel zoom (wheel[0] is deltaY: positive when scrolling // down -> zoom out; negative when scrolling up -> zoom in). The wheel // mutates a TARGET distance which the actual cameraDistance smoothly // lerps toward each frame in _updateController, giving a glide-zoom. if (wheel[0] !== 0) { this._targetCameraDistance = math.clamp( this._targetCameraDistance + wheel[0] * this.zoomSpeed, this.cameraDistanceMin, this.cameraDistanceMax ); } // update state this._state.axis.add(v.set( (key[keyCode.D] - key[keyCode.A]) + (key[keyCode.RIGHT] - key[keyCode.LEFT]), 0, (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]; // grounded raycast (matches FPS controller convention) const start = this.entity.getPosition(); const end = v.copy(start).add(Vec3.DOWN); end.y -= 0.1; const sys = /** @type {RigidBodyComponentSystem} */ (this._rigidbody.system); this._grounded = !!sys.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]); this._updateController(frame, dt); } destroy() { this._desktopInput.destroy(); this._mobileInput.destroy(); this._gamepadInput.destroy(); } } export { ThirdPersonController };