@needle-tools/car-physics
Version:
Car physics for Needle Engine: Create physical cars with ease
270 lines (226 loc) • 10 kB
text/typescript
import { Behaviour, EventList, getTempQuaternion, getTempVector, Mathf, OrbitControls, serializable } from "@needle-tools/engine";
import { CarPhysics } from "./CarPhysics";
import { Vector3, Quaternion } from "three";
export class CarController extends Behaviour {
carPhysics?: CarPhysics | null;
autoReset: boolean = true;
manualReset: boolean = true;
onReset: EventList<void> = new EventList<void>();
/**
* Resets the car to the starting position and orientation
*/
reset() {
this.carPhysics?.teleport(this.posOnStart, this.rotOnStart, true);
// reset orbit control start pos
const orbt = this.context.mainCamera.getComponent(OrbitControls);
orbt?.setCameraTargetPosition(this.camStartPos!, true);
this.onReset?.invoke();
}
private posOnStart!: Vector3;
private rotOnStart!: Quaternion;
private camStartPos!: Vector3;
start() {
// save start orientation
this.posOnStart = this.gameObject.worldPosition.clone();
this.rotOnStart = this.gameObject.worldQuaternion.clone();
this.camStartPos = this.context.mainCamera.position.clone();
}
onEnable() {
this.carPhysics ||= this.gameObject.getComponent(CarPhysics);
window.addEventListener("blur", this.onBlur);
}
onDisable(): void {
window.removeEventListener("blur", this.onBlur);
}
onBeforeRender() {
this.handleInput();
if (this.manualReset && this.context.input.isKeyDown("r")) {
this.reset();
}
if (this.autoReset) {
this.resetWhenRolledOver();
this.resetWhenFallingoff();
}
}
private onBlur = (_se: FocusEvent) => {
if (!this.context.application.hasFocus) {
const gamepad = navigator.getGamepads()?.[0];
if (gamepad) {
// stop gamepad rumble when the scene loses focus
gamepad.vibrationActuator?.playEffect("dual-rumble", {
startDelay: 0,
duration: 0,
weakMagnitude: 1,
strongMagnitude: 1,
});
}
}
}
private _lastResetTime: number = -1;
private resetWhenFallingoff() {
const minAirTime = 5;
if (this.carPhysics && this.carPhysics.airtime > minAirTime) {
const timeSinceLastReset = this.context.time.realtimeSinceStartup - this._lastResetTime;
if (timeSinceLastReset > minAirTime) {
this._lastResetTime = this.context.time.realtimeSinceStartup;
this.reset();
}
}
}
private rolledOverDuration: number = 0;
private resetWhenRolledOver() {
if (!this.carPhysics) return;
const isRolledOver = this.gameObject.worldUp.dot(getTempVector(0, 1, 0)) < 0.65;
const velocity = this.carPhysics.rigidbody.getVelocity();
const isSlow = velocity.length() < 0.1;
if (isRolledOver && isSlow) {
this.rolledOverDuration += this.context.time.deltaTime;
}
else {
this.rolledOverDuration = 0;
}
if (this.rolledOverDuration > 1) {
this.rescueVehicle();
}
}
// TODO: add raycast to determine normal of the surface the car is resetting to
private async rescueVehicle() {
if (!this.carPhysics) return;
const pos = this.gameObject.worldPosition;
pos.y += 1;
const fwd = this.gameObject.worldForward;
fwd.y = 0;
fwd.normalize();
const rot = getTempQuaternion().setFromUnitVectors(getTempVector(0, 0, -1), fwd);
this.carPhysics.teleport(pos, rot);
}
private _lastVehicleVelocity: number = 0;
private _lastHeroRumbleTime: number = -1;
private _currentSteer = 0;
private _currentSteerAccum = 0;
private handleInput() {
if (!this.carPhysics?.vehicle) return;
let steer = 0;
let accel = 0;
let breakVal = 0;
if (this.context.xr) {
accel += this.context.xr.rightController?.getButton("a-button")?.value || 0;
accel -= this.context.xr.leftController?.getButton("x-button")?.value || 0;
const squeezeLeft = this.context.xr.rightController?.getButton("xr-standard-squeeze")?.value || 0;
const squeezeRight = this.context.xr.leftController?.getButton("xr-standard-squeeze")?.value || 0;
if (squeezeLeft > .5 && squeezeRight > .5) {
const yDiff = this.context.xr.leftController!.gripPosition.y - this.context.xr.rightController!.gripPosition.y;
steer = Mathf.clamp(yDiff, -2, 2);
}
}
else {
if (this.context.input.isKeyPressed("a") || this.context.input.isKeyPressed("ArrowLeft")) {
steer -= 1;
}
else if (this.context.input.isKeyPressed("d") || this.context.input.isKeyPressed("ArrowRight")) {
steer += 1;
}
if (this.context.input.isKeyPressed("s") || this.context.input.isKeyPressed("ArrowDown")) {
accel -= 1;
}
if (this.context.input.isKeyPressed("w") || this.context.input.isKeyPressed("ArrowUp")) {
accel += 1;
}
if(this.context.input.isKeyPressed(" ")) {
breakVal += 1;
}
}
const gamepad = navigator.getGamepads()?.[0];
if (gamepad?.connected) {
const sideAxis = gamepad.axes[0];
const forwardAxis = gamepad.axes[1];
if (Math.abs(sideAxis) > .01) { // make sure the tiny deadzone is not used
const negative = sideAxis < 0 ? -1 : 1;
steer += Math.pow(sideAxis, 2) * negative;
}
if (Math.abs(forwardAxis) > .01) { // make sure the tiny deadzone is not used
accel -= forwardAxis;
}
const aButton = gamepad.buttons[0];
const bButton = gamepad.buttons[1];
const ltButton = gamepad.buttons[6];
const rtButton = gamepad.buttons[7];
if (aButton.pressed || rtButton.pressed) {
accel += 1;
}
if (bButton.pressed || ltButton.pressed) {
accel -= 1;
}
const xButton = gamepad.buttons[2];
if (xButton.pressed) {
this.reset();
}
const velocity = this.carPhysics.velocity.length();
// base motor rumble
const timeSinceHeroRumble = this.context.time.realtimeSinceStartup - this._lastHeroRumbleTime;
if (timeSinceHeroRumble > 0.3) {
// base motor rumble
if (velocity > .01) {
gamepad.vibrationActuator?.playEffect("dual-rumble", {
startDelay: 0,
duration: this.context.time.deltaTime,
weakMagnitude: .1,
strongMagnitude: .1,
});
}
// wheels force rumble
const wheels = this.carPhysics.wheels;
const maxForce = 200;
let largestForce = 0;
for (const wheel of wheels) {
const force = this.carPhysics.vehicle.wheelSuspensionForce(wheel.index);
// if (force != undefined) suspensionForce += force;
if (force && force < maxForce) {
const factor = 1 - (force / maxForce);
largestForce = Math.max(largestForce, factor);
}
}
if (largestForce > 0) {
const expFactor = Math.pow(largestForce, 2);
gamepad.vibrationActuator?.playEffect("dual-rumble", {
startDelay: 0,
duration: largestForce * 500,
weakMagnitude: expFactor * 1.0,
strongMagnitude: expFactor * 1.0,
});
}
}
// if the car hits something
if (velocity) {
const lastVelocity = this._lastVehicleVelocity;
this._lastVehicleVelocity = velocity;
const diff = lastVelocity - velocity;
if (diff > 1) {
this._lastHeroRumbleTime = this.context.time.realtimeSinceStartup;
gamepad.vibrationActuator?.playEffect("dual-rumble", {
startDelay: 0,
duration: 150,
weakMagnitude: Mathf.clamp01(diff / 3),
strongMagnitude: Mathf.clamp01(diff / 3),
});
}
}
}
// if (Math.abs(steer) > .02) {
// this._currentSteerAccum += steer * this.context.time.deltaTime / .1;
// }
// else {
// this._currentSteerAccum = Mathf.lerp(this._currentSteerAccum, 0, this.context.time.deltaTime / .1);
// }
// this._currentSteerAccum = Mathf.clamp(this._currentSteerAccum, -1, 1);
steer *= Math.max(.2, Math.min(1, 2 * Math.abs(this.carPhysics.currentSteer)));
this._currentSteer = Mathf.lerp(this._currentSteer, steer, this.context.time.deltaTime / .12);
this.carPhysics.steerImpulse(this._currentSteer);
this.carPhysics.breakImpulse(breakVal);
this.carPhysics.accelerationImpulse(accel);
}
}