@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.
308 lines • 12.8 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Quaternion, Ray, Vector2, Vector3 } from "three";
import { Mathf } from "../engine/engine_math.js";
import { RaycastOptions } from "../engine/engine_physics.js";
import { serializable } from "../engine/engine_serialization.js";
import { getWorldPosition } from "../engine/engine_three_utils.js";
import { getParam } from "../engine/engine_utils.js";
import { Animator } from "./Animator.js";
import { CapsuleCollider } from "./Collider.js";
import { Behaviour } from "./Component.js";
import { Rigidbody } from "./RigidBody.js";
const debug = getParam("debugcharactercontroller");
/**
* The [CharacterController](https://engine.needle.tools/docs/api/CharacterController) adds a capsule collider and rigidbody to the object, constrains rotation, and provides movement and grounded state.
* It is designed for typical character movement in 3D environments.
*
* The controller automatically:
* - Creates a {@link CapsuleCollider} if one doesn't exist
* - Creates a {@link Rigidbody} if one doesn't exist
* - Locks rotation on all axes to prevent tipping over
* - Tracks ground contact for jump detection
*
* @example Basic character movement
* ```ts
* export class MyCharacter extends Behaviour {
* @serializable(CharacterController)
* controller?: CharacterController;
*
* update() {
* const input = this.context.input;
* const move = new Vector3();
* if (input.isKeyPressed("KeyW")) move.z = 0.1;
* if (input.isKeyPressed("KeyS")) move.z = -0.1;
* this.controller?.move(move);
* }
* }
* ```
*
* @summary Character Movement Controller
* @category Character
* @group Components
* @see {@link CharacterControllerInput} for ready-to-use input handling
* @see {@link Rigidbody} for physics configuration
* @see {@link CapsuleCollider} for collision shape
*/
export class CharacterController extends Behaviour {
/** Center offset of the capsule collider in local space */
center = new Vector3(0, 0, 0);
/** Radius of the capsule collider */
radius = .5;
/** Height of the capsule collider */
height = 2;
_rigidbody = null;
get rigidbody() {
if (this._rigidbody)
return this._rigidbody;
this._rigidbody = this.gameObject.getComponent(Rigidbody);
if (!this._rigidbody)
this._rigidbody = this.gameObject.addComponent(Rigidbody);
return this.rigidbody;
}
_activeGroundCollisions;
awake() {
this._activeGroundCollisions = new Set();
}
onEnable() {
const rb = this.rigidbody;
let collider = this.gameObject.getComponent(CapsuleCollider);
if (!collider)
collider = this.gameObject.addComponent(CapsuleCollider);
collider.center.copy(this.center);
collider.radius = this.radius;
collider.height = this.height;
// discard any rotation besides Y axis
const wForward = new Vector3(0, 0, 1);
const wRight = new Vector3(1, 0, 0);
const wUp = new Vector3(0, 1, 0);
const fwd = this.gameObject.getWorldDirection(new Vector3());
fwd.y = 0;
const sign = wRight.dot(fwd) < 0 ? -1 : 1;
const angleY = wForward.angleTo(fwd) * sign;
this.gameObject.setRotationFromAxisAngle(wUp, angleY);
rb.lockRotationX = true;
rb.lockRotationY = true;
rb.lockRotationZ = true;
}
/**
* Moves the character by adding the given vector to its position.
* Movement is applied directly without physics simulation.
* @param vec The movement vector to apply
*/
move(vec) {
this.gameObject.position.add(vec);
}
onCollisionEnter(col) {
// contacts can be empty, ignoring such collision results in a stuck grounded state
// namely caused by mesh colliders
if (col.contacts.length == 0 || col.contacts.some(contact => contact.normal.y > 0.2)) {
this._activeGroundCollisions.add(col);
if (debug) {
console.log(`Collision(${this._activeGroundCollisions.size}): ${col.contacts.map(c => c.normal.y.toFixed(2)).join(", ")} - ${this.isGrounded}`);
}
}
}
onCollisionExit(col) {
this._activeGroundCollisions.delete(col);
if (debug) {
console.log(`Collision(${this._activeGroundCollisions.size}) - ${this.isGrounded}`);
}
}
/** Returns true if the character is currently touching the ground */
get isGrounded() { return this._activeGroundCollisions.size > 0; }
_contactVelocity = new Vector3();
/**
* Returns the combined velocity of all objects the character is standing on.
* Useful for moving platforms - add this to your movement for proper platform riding.
*/
get contactVelocity() {
this._contactVelocity.set(0, 0, 0);
for (const col of this._activeGroundCollisions) {
const vel = this.context.physics.engine?.getLinearVelocity(col.collider);
if (!vel)
continue;
// const friction = col.collider.sharedMaterial?.dynamicFriction || 1;
this._contactVelocity.x += vel.x;
this._contactVelocity.y += vel.y;
this._contactVelocity.z += vel.z;
}
return this._contactVelocity;
}
}
__decorate([
serializable(Vector3)
], CharacterController.prototype, "center", void 0);
__decorate([
serializable()
], CharacterController.prototype, "radius", void 0);
__decorate([
serializable()
], CharacterController.prototype, "height", void 0);
/**
* CharacterControllerInput handles user input to control a {@link CharacterController}.
* It supports movement, looking around, jumping, and double jumping.
*
* Default controls:
* - **W/S**: Move forward/backward
* - **A/D**: Rotate left/right
* - **Space**: Jump (supports double jump)
*
* The component automatically sets animator parameters:
* - `running` (bool): True when moving
* - `jumping` (bool): True when starting a jump
* - `doubleJump` (bool): True during double jump
* - `falling` (bool): True when falling from height
*
* @example Custom input handling
* ```ts
* const input = this.gameObject.getComponent(CharacterControllerInput);
* input?.move(new Vector2(0, 1)); // Move forward
* input?.jump(); // Trigger jump
* ```
*
* @summary User Input for Character Controller
* @category Character
* @group Components
* @see {@link CharacterController} for the movement controller
* @see {@link Animator} for animation integration
*/
export class CharacterControllerInput extends Behaviour {
/** The CharacterController to drive with input */
controller;
/** Movement speed multiplier */
movementSpeed = 2;
/** Rotation speed multiplier */
rotationSpeed = 2;
/** Impulse force applied when jumping from ground */
jumpForce = 1;
/** Impulse force applied for the second jump (set to 0 to disable double jump) */
doubleJumpForce = 2;
/** Optional Animator for character animations */
animator;
lookForward = true;
awake() {
this._currentRotation = new Quaternion();
}
update() {
const input = this.context.input;
if (input.isKeyPressed("KeyW"))
this.moveInput.y += 1;
else if (input.isKeyPressed("KeyS"))
this.moveInput.y -= 1;
if (input.isKeyPressed("KeyD"))
this.lookInput.x += 1;
else if (input.isKeyPressed("KeyA"))
this.lookInput.x -= 1;
this.jumpInput ||= input.isKeyDown("Space");
}
move(move) {
this.moveInput.add(move);
}
look(look) {
this.lookInput.add(look);
}
jump() {
this.jumpInput = true;
}
lookInput = new Vector2(0, 0);
moveInput = new Vector2(0, 0);
jumpInput = false;
onBeforeRender() {
this.handleInput(this.moveInput, this.lookInput, this.jumpInput);
this.lookInput.set(0, 0);
this.moveInput.set(0, 0);
this.jumpInput = false;
}
_currentSpeed = new Vector3(0, 0, 0);
_currentAngularSpeed = new Vector3(0, 0, 0);
_temp = new Vector3(0, 0, 0);
_jumpCount = 0;
_currentRotation;
handleInput(move, look, jump) {
if (this.controller?.isGrounded) {
this._jumpCount = 0;
if (this.doubleJumpForce > 0)
this.animator?.setBool("doubleJump", false);
}
this._currentSpeed.z += move.y * this.movementSpeed * this.context.time.deltaTime;
this.animator?.setBool("running", move.length() > 0.01);
this.animator?.setBool("jumping", this.controller?.isGrounded === true && jump);
this._temp.copy(this._currentSpeed);
this._temp.applyQuaternion(this.gameObject.quaternion);
if (this.controller)
this.controller.move(this._temp);
else
this.gameObject.position.add(this._temp);
this._currentAngularSpeed.y += Mathf.toRadians(-look.x * this.rotationSpeed) * this.context.time.deltaTime;
if (this.lookForward && Math.abs(this._currentAngularSpeed.y) < .01) {
const forwardVector = this.context.mainCameraComponent.forward;
forwardVector.y = 0;
forwardVector.normalize();
this._currentRotation.setFromUnitVectors(new Vector3(0, 0, 1), forwardVector);
this.gameObject.quaternion.slerp(this._currentRotation, this.context.time.deltaTime * 10);
}
this.gameObject.rotateY(this._currentAngularSpeed.y);
this._currentSpeed.multiplyScalar(1 - this.context.time.deltaTime * 10);
this._currentAngularSpeed.y *= 1 - this.context.time.deltaTime * 10;
if (this.controller && jump && this.jumpForce > 0) {
let canJump = this.controller?.isGrounded;
if (this.doubleJumpForce > 0 && !this.controller?.isGrounded && this._jumpCount === 1) {
canJump = true;
this.animator?.setBool("doubleJump", true);
}
if (canJump) {
this._jumpCount += 1;
// TODO: factor in mass
const rb = this.controller.rigidbody;
// const fullJumpHoldLength = .1;
const factor = this._jumpCount === 2 ? this.doubleJumpForce : this.jumpForce; // Mathf.clamp((this.context.time.time - this._jumpDownTime), 0, fullJumpHoldLength) / fullJumpHoldLength;
rb.applyImpulse(new Vector3(0, 1, 0).multiplyScalar(factor));
}
}
if (this.controller) {
// TODO: should probably raycast to the ground or check if we're still in the jump animation
const verticalSpeed = this.controller?.rigidbody.getVelocity().y;
if (verticalSpeed < -1) {
if (!this._raycastOptions.ray)
this._raycastOptions.ray = new Ray();
this._raycastOptions.ray.origin.copy(getWorldPosition(this.gameObject));
this._raycastOptions.ray.direction.set(0, -1, 0);
const currentLayer = this.layer;
this.gameObject.layers.disableAll();
this.gameObject.layers.set(2);
const hits = this.context.physics.raycast(this._raycastOptions);
this.gameObject.layers.set(currentLayer);
if ((hits.length && hits[0].distance > 2 || verticalSpeed < -10)) {
this.animator?.setBool("falling", true);
}
}
else
this.animator?.setBool("falling", false);
}
}
_raycastOptions = new RaycastOptions();
}
__decorate([
serializable(CharacterController)
], CharacterControllerInput.prototype, "controller", void 0);
__decorate([
serializable()
], CharacterControllerInput.prototype, "movementSpeed", void 0);
__decorate([
serializable()
], CharacterControllerInput.prototype, "rotationSpeed", void 0);
__decorate([
serializable()
], CharacterControllerInput.prototype, "jumpForce", void 0);
__decorate([
serializable()
], CharacterControllerInput.prototype, "doubleJumpForce", void 0);
__decorate([
serializable(Animator)
], CharacterControllerInput.prototype, "animator", void 0);
//# sourceMappingURL=CharacterController.js.map