Das Submodul zeigte auf GitHub, der eingetragene Stand 028c652 lag aber nur lokal vor: GitHub-Master ac39682 plus Showcase mit Figuren und launch.json. Frische Klone konnten den Controller deshalb nicht laden. Jetzt liegt dieser Stand als normaler Ordner im Repo, ohne Verweis auf GitHub. dist/ und node_modules/ im Controller bleiben ignoriert und werden wie bisher lokal gebaut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
834 lines
36 KiB
TypeScript
834 lines
36 KiB
TypeScript
import * as THREE from "three";
|
|
import type { playerController } from "../PlayerController";
|
|
import { loadVehicleModel as loadVehicleModelUtil } from "../utils/vehicleLoader";
|
|
import { DEFAULT_MAX_SUSPENSION_TRAVEL, DEFAULT_WHEEL_PHYSICS } from "../utils/vehicleController";
|
|
import { applyCapsuleCollision, createCollisionTemps } from "../utils/capsuleCollision";
|
|
import type { VehicleInstance, VehicleOptions, KinematicColliderEntry, VehicleWheelColliderUserData } from "../types";
|
|
import { CollisionGroup } from "../collision/groups";
|
|
import { createObbObbTemps, resolveObbObb } from "../collision/obbObb";
|
|
|
|
export class VehicleSystem {
|
|
private ctrl: playerController; // 主控制器引用
|
|
|
|
list: VehicleInstance[] = []; // 车辆实例列表
|
|
active: VehicleInstance | null = null; // 当前乘坐车辆
|
|
meshSkipIds: number[] = []; // 全部车辆外观 collider id,网格查询时跳过
|
|
vehicleLength = 6; // 车辆模型归一化后的最大边长度
|
|
params = {
|
|
debug: { showPhysicsBox: false, showWheelRays: false, showWheelTravel: false, showWheelSpheres: false }, // 调试显示
|
|
chassis: { density: 1, linearDamping: 0.05, angularDamping: 0.5 }, // 车身参数
|
|
model: { rotation: -Math.PI / 2 }, // 模型旋转
|
|
power: { acceleration: 5, deceleration: 5, maxSpeed: 100 }, // 动力参数
|
|
steering: { maxSteerAngle: Math.PI / 5, steerTime: 0.45, steerReturnTimeSlow: 0.55, steerReturnTimeFast: 0.4, highSpeedSteerScale: 0.3 }, // 转向:打满/回正时间(秒),高速收舵
|
|
grip: {
|
|
maxG: 1.2,
|
|
sideFrictionIdle: 1,
|
|
sideFrictionFrontMin: 0.55,
|
|
sideFrictionRearMin: 0.45,
|
|
handbrakeRearFriction: 0.35,
|
|
handbrakeRearDriveScale: 0.65,
|
|
handbrakeReleaseTime: 0.15,
|
|
wheelbaseRatio: 0.55,
|
|
}, // 抓地预算
|
|
suspension: {
|
|
maxTravel: DEFAULT_MAX_SUSPENSION_TRAVEL,
|
|
stiffness: DEFAULT_WHEEL_PHYSICS.suspensionStiffness,
|
|
compression: DEFAULT_WHEEL_PHYSICS.suspensionCompression,
|
|
relaxation: DEFAULT_WHEEL_PHYSICS.suspensionRelaxation,
|
|
maxForce: DEFAULT_WHEEL_PHYSICS.maxSuspensionForce,
|
|
frictionSlip: DEFAULT_WHEEL_PHYSICS.frictionSlip,
|
|
sideFrictionStiffness: DEFAULT_WHEEL_PHYSICS.sideFrictionStiffness,
|
|
rollInfluence: DEFAULT_WHEEL_PHYSICS.rollInfluence,
|
|
},
|
|
followVehicleDirection: true, // 相机跟随方向
|
|
};
|
|
|
|
boardingPadding = 0.25; // 上车/下车余量基准值(按车辆 scale 缩放)
|
|
parkingCreepThreshold = 0.05; // 驻车状态下清除低速蠕动的水平速度阈值
|
|
private handbrakeBlend = 0; // 1 为手刹全开,松键后插值回 0
|
|
|
|
private scratchLocal = new THREE.Vector3();
|
|
private scratchWorld = new THREE.Vector3();
|
|
private scratchForward = new THREE.Vector3();
|
|
private scratchUp = new THREE.Vector3();
|
|
private scratchQuat = new THREE.Quaternion();
|
|
private scratchDown = new THREE.Vector3(0, -1, 0);
|
|
private wheelSphereDir = new THREE.Vector3();
|
|
private carryPrevInv = new THREE.Matrix4();
|
|
private carryYaw = new THREE.Quaternion();
|
|
private carryUp = new THREE.Vector3(0, 1, 0);
|
|
private raycaster = new THREE.Raycaster();
|
|
private exitCheckTemps = createCollisionTemps();
|
|
private obbTemps = createObbObbTemps();
|
|
|
|
constructor(ctrl: playerController) {
|
|
this.ctrl = ctrl;
|
|
(this.raycaster as any).firstHitOnly = true;
|
|
}
|
|
|
|
/** 切换车辆底盘物理盒。 */
|
|
setPhysicsDebugVisible(visible: boolean): void {
|
|
this.params.debug.showPhysicsBox = visible;
|
|
this.syncDebugVisibility();
|
|
}
|
|
|
|
/** 同步全部车辆调试对象的显隐。 */
|
|
syncDebugVisibility(): void {
|
|
for (const v of this.list) {
|
|
if (v.physicsBoxMesh) {
|
|
if (this.params.debug.showPhysicsBox) {
|
|
if (!v.vehicleGroup.children.includes(v.physicsBoxMesh)) v.vehicleGroup.add(v.physicsBoxMesh);
|
|
} else {
|
|
v.vehicleGroup.remove(v.physicsBoxMesh);
|
|
}
|
|
}
|
|
if (v.wheelRayDebug) v.wheelRayDebug.visible = this.params.debug.showWheelRays;
|
|
if (v.wheelTravelDebug) v.wheelTravelDebug.visible = this.params.debug.showWheelTravel;
|
|
if (v.wheelSphereDebug) {
|
|
v.wheelSphereDebug.visible = this.params.debug.showWheelSpheres;
|
|
if (v.wheelSphereDebug.visible) this.syncWheelSphereDebug(v);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 由车身水平包围圆和人物胶囊半径推算上车范围。 */
|
|
boardingRadius(v: VehicleInstance): number {
|
|
return Math.hypot(v.halfExtents.x, v.halfExtents.z)
|
|
+ this.capsuleRadius()
|
|
+ this.boardingPadding * v.scale;
|
|
}
|
|
|
|
/** 返回人物到车辆中心的水平距离,高度超出车辆邻域时返回 Infinity。 */
|
|
boardingDistance(v: VehicleInstance, position: THREE.Vector3): number {
|
|
v.vehicleGroup.updateMatrixWorld(true);
|
|
this.scratchLocal.copy(position);
|
|
v.vehicleGroup.worldToLocal(this.scratchLocal);
|
|
const verticalLimit = v.halfExtents.y + this.capsuleHeight();
|
|
if (Math.abs(this.scratchLocal.y) > verticalLimit) return Infinity;
|
|
return Math.hypot(this.scratchLocal.x, this.scratchLocal.z);
|
|
}
|
|
|
|
/** 是否处于车辆中心的上车范围内。 */
|
|
isInBoardingRange(v: VehicleInstance, position: THREE.Vector3): boolean {
|
|
return this.boardingDistance(v, position) <= this.boardingRadius(v);
|
|
}
|
|
|
|
// 加载车辆模型
|
|
async load(opts: VehicleOptions): Promise<VehicleInstance | undefined> {
|
|
try {
|
|
const instance = await loadVehicleModelUtil(opts, {
|
|
loader: this.ctrl.loader,
|
|
scene: this.ctrl.scene,
|
|
vehicleParams: this.params,
|
|
vehicleLength: this.vehicleLength,
|
|
});
|
|
|
|
this.list.push(instance);
|
|
const kin = this.ctrl.addCollider({
|
|
motion: "kinematic",
|
|
shape: { kind: "mesh", source: instance.vehicleGroup },
|
|
follow: instance.vehicleGroup,
|
|
});
|
|
instance.meshColliderId = kin.id;
|
|
const chassis = this.ctrl.addCollider({
|
|
motion: "dynamic",
|
|
shape: { kind: "box", halfExtents: instance.halfExtents.clone() },
|
|
groups: CollisionGroup.VEHICLE,
|
|
mask: CollisionGroup.DEFAULT | CollisionGroup.VEHICLE | CollisionGroup.DEBRIS,
|
|
userData: instance,
|
|
simulate: false, // 默认会建 DynamicBoxBody;底盘改由 VehicleRigidBody 推进,此处只登记
|
|
});
|
|
instance.chassisColliderId = chassis.id;
|
|
instance.wheelColliderIds = this.registerWheelSpheres(instance);
|
|
this.attachWheelSphereDebug(instance);
|
|
return instance;
|
|
} catch (e) {
|
|
console.error("加载车辆模型失败:", e);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// 触发上车流程
|
|
enter() {
|
|
if (!this.list.length || this.ctrl.controllerMode === 1) return;
|
|
|
|
// 查找最近可上车的车辆
|
|
let nearest: VehicleInstance | null = null;
|
|
let nearestDist = Infinity;
|
|
const pos = this.ctrl.playerCapsule.position;
|
|
for (const v of this.list) {
|
|
const dist = this.boardingDistance(v, pos);
|
|
if (dist <= this.boardingRadius(v) && dist < nearestDist) {
|
|
nearestDist = dist;
|
|
nearest = v;
|
|
}
|
|
}
|
|
|
|
if (!nearest) return;
|
|
// 车辆移动中不允许上车
|
|
const vel = nearest.chassisBody.linvel();
|
|
if (Math.hypot(vel.x, vel.z) > 0.1) return;
|
|
this.releaseParkingBrake(nearest);
|
|
this.active = nearest;
|
|
const c = this.ctrl;
|
|
c.controllerMode = 1;
|
|
c.playerVelocity.set(0, 0, 0);
|
|
c.activeDynamicBody = null;
|
|
c.mobileControls?.syncControllerModeBtn(1);
|
|
c.cam.setOverShoulder(false);
|
|
c.animation.playByName("driving");
|
|
c.syncMountedPlayer(nearest);
|
|
c.syncDebugVisibility();
|
|
c.onVehicleEnter?.(nearest);
|
|
}
|
|
|
|
// 触发下车流程
|
|
exit() {
|
|
const c = this.ctrl;
|
|
const v = this.active;
|
|
if (!v) return;
|
|
|
|
this.applyParkingBrake(v, c.getCurrentDelta() || 1 / 60);
|
|
this.findExitPosition(v, this.scratchWorld);
|
|
this.getDriverForward(v, this.scratchForward);
|
|
c.controllerMode = 0;
|
|
c.mobileControls?.syncControllerModeBtn(0);
|
|
c.cam.setOverShoulder(c.enableOverShoulderView);
|
|
c.leaveVehicleAt(this.scratchWorld, this.scratchForward);
|
|
c.animation.playByName("idle");
|
|
c.syncDebugVisibility();
|
|
this.handbrakeBlend = 0;
|
|
c.onVehicleExit?.(v);
|
|
}
|
|
|
|
// 物理步进前更新车辆控制器
|
|
preparePhysics(delta: number) {
|
|
if (this.ctrl.controllerMode === 1 && this.active) this.applyDriving(delta, this.active);
|
|
}
|
|
|
|
// 物理步进后同步车辆视觉
|
|
finishPhysics(delta: number) {
|
|
for (const v of this.list) {
|
|
const parked = this.ctrl.controllerMode !== 1 || this.active !== v;
|
|
if (parked) this.applyParkingBrake(v, delta);
|
|
v.vehicleController.updateVehicle(delta, this.ctrl.getVehicleGroundMeshes(v));
|
|
this.applyKinematicCarry(v);
|
|
}
|
|
this.resolveVehiclePairs();
|
|
for (const v of this.list) {
|
|
const parked = this.ctrl.controllerMode !== 1 || this.active !== v;
|
|
const vel = v.chassisBody.linvel();
|
|
const speed = Math.hypot(vel.x, vel.z);
|
|
const max = v.maxSpeed / 3.6;
|
|
if (speed > max) {
|
|
const s = max / speed;
|
|
v.chassisBody.setLinvel({ x: vel.x * s, y: vel.y, z: vel.z * s });
|
|
} else if (parked && speed > 0 && speed <= this.parkingCreepThreshold) {
|
|
let hasWheelContact = false;
|
|
for (let i = 0; i < v.vehicleController.numWheels(); i++) {
|
|
if (v.vehicleController.wheelIsInContact(i)) { hasWheelContact = true; break; }
|
|
}
|
|
if (hasWheelContact) v.chassisBody.setLinvel({ x: 0, y: vel.y, z: 0 });
|
|
}
|
|
this.syncVehicleVisual(v, delta);
|
|
}
|
|
if (this.ctrl.controllerMode === 1 && this.active) this.ctrl.syncMountedPlayer(this.active);
|
|
}
|
|
|
|
/** 两车底盘盒对盒:跳过自身,驻车中的车仍可被撞开。 */
|
|
private resolveVehiclePairs() {
|
|
const n = this.list.length;
|
|
if (n < 2) return;
|
|
for (let iter = 0; iter < 2; iter++) {
|
|
for (let i = 0; i < n; i++) {
|
|
for (let j = i + 1; j < n; j++) {
|
|
resolveObbObb(this.list[i].chassisBody, this.list[j].chassisBody, this.obbTemps);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 更新车辆驾驶
|
|
private applyDriving(delta: number, v: VehicleInstance) {
|
|
const c = this.ctrl;
|
|
const { vehicleController, chassisBody } = v;
|
|
|
|
// 坡度补偿
|
|
const rotation = chassisBody.rotation();
|
|
this.scratchQuat.set(rotation.x, rotation.y, rotation.z, rotation.w);
|
|
const forward = this.scratchForward.copy(v.forwardLocal).applyQuaternion(this.scratchQuat);
|
|
const throttle = Number(c.input.fwd) - Number(c.input.bkd);
|
|
const sinTheta = Math.max(-1, Math.min(1, forward.y));
|
|
const gEff = 9.81 * v.scale;
|
|
const extraAccel = (throttle * sinTheta > 0.05) ? Math.abs(sinTheta) * gEff : 0;
|
|
const wheelCount = Math.max(1, vehicleController.numWheels());
|
|
const mass = chassisBody.mass();
|
|
const linv = chassisBody.linvel();
|
|
const speed01 = Math.min(1, Math.hypot(linv.x, linv.z) / Math.max(0.01, v.maxSpeed / 3.6));
|
|
|
|
// 转向:参数为走完满舵行程的时间(秒);车速越高可用转角越小
|
|
const { maxSteerAngle, steerTime, steerReturnTimeSlow, steerReturnTimeFast, highSpeedSteerScale = 0.3 } = v.steering;
|
|
const currentSteering = vehicleController.wheelSteering(0) || 0;
|
|
const steerDir = Number(c.input.lft) - Number(c.input.rgt);
|
|
const speedSteer = 1 - (1 - highSpeedSteerScale) * speed01 * speed01;
|
|
const steerLimit = maxSteerAngle * speedSteer;
|
|
const targetSteering = steerLimit * steerDir;
|
|
const responseTime = steerDir === 0
|
|
? steerReturnTimeSlow + (steerReturnTimeFast - steerReturnTimeSlow) * speed01
|
|
: steerTime;
|
|
const maxStep = Math.max(steerLimit, 1e-4) / Math.max(1e-4, responseTime) * delta;
|
|
const steeringDelta = targetSteering - currentSteering;
|
|
const steering = currentSteering + Math.sign(steeringDelta) * Math.min(Math.abs(steeringDelta), maxStep);
|
|
vehicleController.setWheelSteering(0, steering);
|
|
vehicleController.setWheelSteering(1, steering);
|
|
|
|
// 抓地预算
|
|
const { maxG, sideFrictionIdle, sideFrictionFrontMin, sideFrictionRearMin, handbrakeRearFriction, handbrakeRearDriveScale, handbrakeReleaseTime, wheelbaseRatio } = v.grip;
|
|
const vFwd = linv.x * forward.x + linv.y * forward.y + linv.z * forward.z;
|
|
const wheelbase = Math.max(0.01, v.size.l * wheelbaseRatio);
|
|
const latA = vFwd * vFwd * Math.tan(Math.min(1.5, Math.abs(steering))) / wheelbase;
|
|
const latG = Math.min(maxG, latA / gEff);
|
|
const engineForce = throttle * mass * (v.acceleration + extraAccel) / wheelCount;
|
|
|
|
// Shift 漂移:降后轮侧向摩擦甩尾,保留部分后驱维持车速,不额外后轮制动
|
|
if (c.input.shift) this.handbrakeBlend = 1;
|
|
else if (this.handbrakeBlend > 0) {
|
|
this.handbrakeBlend = Math.max(0, this.handbrakeBlend - delta / Math.max(1e-4, handbrakeReleaseTime));
|
|
}
|
|
const rearDriveScale = 1 - this.handbrakeBlend * (1 - handbrakeRearDriveScale);
|
|
for (let i = 0; i < wheelCount; i++) {
|
|
vehicleController.setWheelEngineForce(i, i >= 2 ? engineForce * rearDriveScale : engineForce);
|
|
}
|
|
|
|
const spaceBrake = Number(c.input.space) * mass * v.deceleration / wheelCount * delta;
|
|
for (let i = 0; i < wheelCount; i++) {
|
|
vehicleController.setWheelBrake(i, spaceBrake);
|
|
}
|
|
|
|
const gripUsed = latG / maxG;
|
|
const frontFriction = sideFrictionIdle + (sideFrictionFrontMin - sideFrictionIdle) * gripUsed;
|
|
const gripRearFriction = sideFrictionIdle + (sideFrictionRearMin - sideFrictionIdle) * gripUsed;
|
|
const rearFriction = gripRearFriction + (handbrakeRearFriction - gripRearFriction) * this.handbrakeBlend;
|
|
vehicleController.setWheelSideFrictionStiffness(0, frontFriction);
|
|
vehicleController.setWheelSideFrictionStiffness(1, frontFriction);
|
|
vehicleController.setWheelSideFrictionStiffness(2, rearFriction);
|
|
vehicleController.setWheelSideFrictionStiffness(3, rearFriction);
|
|
}
|
|
|
|
// 翻车复位
|
|
resetUpright() {
|
|
const v = this.active;
|
|
if (!v || this.ctrl.controllerMode !== 1) return;
|
|
const { chassisBody } = v;
|
|
const t = chassisBody.translation();
|
|
chassisBody.setTranslation({ x: t.x, y: t.y + v.size.h, z: t.z });
|
|
chassisBody.setRotation({ x: 0, y: 0, z: 0, w: 1 });
|
|
chassisBody.setLinvel({ x: 0, y: 0, z: 0 });
|
|
chassisBody.setAngvel({ x: 0, y: 0, z: 0 });
|
|
this.handbrakeBlend = 0;
|
|
}
|
|
|
|
// 计算车身外侧的可用下车位置
|
|
private findExitPosition(v: VehicleInstance, out: THREE.Vector3): THREE.Vector3 {
|
|
v.vehicleGroup.updateMatrixWorld(true);
|
|
this.scratchLocal.copy(v.driverSeatPosition).multiplyScalar(v.scale);
|
|
const seatX = Math.max(-v.halfExtents.x, Math.min(v.halfExtents.x, this.scratchLocal.x));
|
|
const side = this.scratchLocal.z >= 0 ? 1 : -1;
|
|
const clearance = this.capsuleRadius() + this.boardingPadding * v.scale;
|
|
const sideOffset = v.halfExtents.z + clearance;
|
|
const endOffset = v.halfExtents.x + clearance;
|
|
const candidates = [
|
|
{ x: seatX, z: side * sideOffset },
|
|
{ x: seatX, z: -side * sideOffset },
|
|
{ x: endOffset, z: 0 },
|
|
{ x: -endOffset, z: 0 },
|
|
];
|
|
let groundedFallback: THREE.Vector3 | null = null;
|
|
const maxDist = v.size.h + this.capsuleHeight() * 3 + 2;
|
|
this.raycaster.far = maxDist;
|
|
|
|
for (const candidate of candidates) {
|
|
this.scratchLocal.set(candidate.x, v.halfExtents.y + this.capsuleHeight() + 0.5, candidate.z);
|
|
v.vehicleGroup.localToWorld(this.scratchLocal);
|
|
out.copy(this.scratchLocal);
|
|
this.raycaster.set(out, this.scratchDown);
|
|
let bestHit: THREE.Intersection | undefined;
|
|
for (const mesh of this.ctrl.getColliderMeshes()) {
|
|
const hits = this.raycaster.intersectObject(mesh, false);
|
|
if (hits.length && (!bestHit || hits[0].distance < bestHit.distance)) {
|
|
bestHit = hits[0];
|
|
}
|
|
}
|
|
if (!bestHit) continue;
|
|
out.copy(bestHit.point);
|
|
out.y += this.ctrl.getCapsuleGroundHeight();
|
|
groundedFallback ??= out.clone();
|
|
if (this.isCharacterPositionFree(out, v)) return out;
|
|
}
|
|
|
|
if (groundedFallback) return out.copy(groundedFallback);
|
|
this.scratchLocal.set(seatX, -v.halfExtents.y + this.ctrl.getCapsuleGroundHeight(), side * sideOffset);
|
|
v.vehicleGroup.localToWorld(this.scratchLocal);
|
|
return out.copy(this.scratchLocal);
|
|
}
|
|
|
|
/** 检测下车点处胶囊是否与静态/其他运动学碰撞体重叠。 */
|
|
private isCharacterPositionFree(position: THREE.Vector3, v: VehicleInstance): boolean {
|
|
const cap = this.ctrl.playerCapsule;
|
|
const info = cap.capsuleInfo;
|
|
if (!info || !this.ctrl.getColliderMeshes().length) return true;
|
|
|
|
const origParent = cap.parent;
|
|
const origPos = cap.position.clone();
|
|
const origQuat = cap.quaternion.clone();
|
|
const skipMesh = this.ctrl.getKinematicColliderEntries().find(e => e.source === v.vehicleGroup)?.mesh;
|
|
this.ctrl.scene.attach(cap);
|
|
cap.position.copy(position);
|
|
cap.updateMatrixWorld(true);
|
|
const before = position.clone();
|
|
for (const mesh of this.ctrl.getColliderMeshes()) {
|
|
if (mesh === skipMesh) continue;
|
|
cap.updateMatrixWorld(true);
|
|
applyCapsuleCollision(cap, info, mesh, this.exitCheckTemps);
|
|
}
|
|
const free = cap.position.distanceTo(before) < info.radius * 0.5;
|
|
origParent?.attach(cap);
|
|
cap.position.copy(origPos);
|
|
cap.quaternion.copy(origQuat);
|
|
cap.updateMatrixWorld(true);
|
|
return free;
|
|
}
|
|
|
|
/** 有轮子打在运动学网格上时,按平台 prev→current 带走车身。 */
|
|
private applyKinematicCarry(v: VehicleInstance) {
|
|
const votes = new Map<KinematicColliderEntry, number>();
|
|
const n = v.vehicleController.numWheels();
|
|
for (let i = 0; i < n; i++) {
|
|
const mesh = v.vehicleController.wheelContactMesh(i);
|
|
if (!mesh) continue;
|
|
const entry = this.ctrl.getKinematicColliderEntries().find(e => e.mesh === mesh);
|
|
if (!entry) continue;
|
|
votes.set(entry, (votes.get(entry) ?? 0) + 1);
|
|
}
|
|
|
|
let best: KinematicColliderEntry | null = null;
|
|
let bestCount = 0;
|
|
for (const [entry, count] of votes) {
|
|
if (count > bestCount) {
|
|
best = entry;
|
|
bestCount = count;
|
|
}
|
|
}
|
|
if (!best) return;
|
|
|
|
this.carryPrevInv.copy(best.prevWorldMatrix).invert();
|
|
this.scratchWorld.copy(v.chassisBody.position).applyMatrix4(this.carryPrevInv);
|
|
v.chassisBody.position.copy(this.scratchWorld).applyMatrix4(best.source.matrixWorld);
|
|
if (best.deltaRotY !== 0) {
|
|
this.carryYaw.setFromAxisAngle(this.carryUp, best.deltaRotY);
|
|
v.chassisBody.quaternion.premultiply(this.carryYaw);
|
|
}
|
|
}
|
|
|
|
// 同步单辆车的模型、车轮和调试盒
|
|
private syncVehicleVisual(v: VehicleInstance, delta = 1 / 60) {
|
|
const t = v.chassisBody.translation();
|
|
const r = v.chassisBody.rotation();
|
|
v.vehicleGroup.position.set(t.x, t.y, t.z);
|
|
v.vehicleGroup.quaternion.set(r.x, r.y, r.z, r.w);
|
|
if (v.wheelRayDebug) v.wheelRayDebug.visible = this.params.debug.showWheelRays;
|
|
if (v.wheelTravelDebug) v.wheelTravelDebug.visible = this.params.debug.showWheelTravel;
|
|
if (v.wheelSphereDebug) {
|
|
v.wheelSphereDebug.visible = this.params.debug.showWheelSpheres;
|
|
if (v.wheelSphereDebug.visible) this.syncWheelSphereDebug(v);
|
|
}
|
|
v.updateWheelVisuals?.(delta);
|
|
}
|
|
|
|
/** 取车辆世界前向。 */
|
|
getForward(v: VehicleInstance, out = new THREE.Vector3()): THREE.Vector3 {
|
|
v.vehicleGroup.updateMatrixWorld(true);
|
|
return out.copy(v.forwardLocal).transformDirection(v.vehicleGroup.matrixWorld).normalize();
|
|
}
|
|
|
|
/** 取驾驶位世界前向。 */
|
|
getDriverForward(v: VehicleInstance, out = new THREE.Vector3()): THREE.Vector3 {
|
|
this.scratchQuat.setFromAxisAngle(this.scratchUp.set(0, 1, 0), v.driverSeatRotation);
|
|
this.scratchLocal.set(0, 0, -1).applyQuaternion(this.scratchQuat);
|
|
v.vehicleGroup.updateMatrixWorld(true);
|
|
return out.copy(this.scratchLocal).transformDirection(v.vehicleGroup.matrixWorld).normalize();
|
|
}
|
|
|
|
/** 取车辆世界上向。 */
|
|
getUp(v: VehicleInstance, out = new THREE.Vector3()): THREE.Vector3 {
|
|
v.vehicleGroup.updateMatrixWorld(true);
|
|
return out.set(0, 1, 0).transformDirection(v.vehicleGroup.matrixWorld).normalize();
|
|
}
|
|
|
|
/**
|
|
* 为每只车轮登记只碰 DEBRIS 的运动学球。
|
|
* 球心每帧由底盘位姿 + 悬挂长度推算,不在此写位置。
|
|
*/
|
|
private registerWheelSpheres(v: VehicleInstance): number[] {
|
|
const ids: number[] = [];
|
|
const n = v.vehicleController.numWheels();
|
|
for (let i = 0; i < n; i++) {
|
|
const wheel = v.vehicleController.wheelAt(i);
|
|
if (!wheel) continue;
|
|
const userData: VehicleWheelColliderUserData = { vehicle: v, wheelIndex: i };
|
|
const handle = this.ctrl.addCollider({
|
|
motion: "kinematic",
|
|
shape: { kind: "sphere", radius: Math.max(1e-4, wheel.radius) },
|
|
groups: CollisionGroup.VEHICLE,
|
|
mask: CollisionGroup.DEBRIS,
|
|
userData,
|
|
});
|
|
ids.push(handle.id);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
/** 创建车轮碰撞球线框,挂在 vehicleGroup 下(底盘局部坐标)。 */
|
|
private attachWheelSphereDebug(v: VehicleInstance): void {
|
|
const group = new THREE.Group();
|
|
group.name = "wheelSphereDebug";
|
|
group.userData.excludeFromCollider = true;
|
|
const geo = new THREE.SphereGeometry(1, 16, 12);
|
|
const mat = new THREE.MeshBasicMaterial({
|
|
color: 0x00e8a0,
|
|
wireframe: true,
|
|
transparent: true,
|
|
opacity: 0.45,
|
|
});
|
|
const n = v.vehicleController.numWheels();
|
|
for (let i = 0; i < n; i++) {
|
|
const wheel = v.vehicleController.wheelAt(i);
|
|
if (!wheel) continue;
|
|
const mesh = new THREE.Mesh(geo, mat);
|
|
mesh.name = `wheelSphereDebug_${i}`;
|
|
mesh.frustumCulled = false;
|
|
mesh.renderOrder = 22;
|
|
mesh.scale.setScalar(Math.max(1e-4, wheel.radius));
|
|
group.add(mesh);
|
|
}
|
|
group.visible = this.params.debug.showWheelSpheres;
|
|
v.vehicleGroup.add(group);
|
|
v.wheelSphereDebug = group;
|
|
this.syncWheelSphereDebug(v);
|
|
}
|
|
|
|
/** 按悬挂长度把轮球线框对齐到轮心(底盘局部)。 */
|
|
private syncWheelSphereDebug(v: VehicleInstance): void {
|
|
const group = v.wheelSphereDebug;
|
|
if (!group) return;
|
|
let meshIndex = 0;
|
|
const n = v.vehicleController.numWheels();
|
|
for (let i = 0; i < n; i++) {
|
|
const wheel = v.vehicleController.wheelAt(i);
|
|
const mesh = group.children[meshIndex] as THREE.Mesh | undefined;
|
|
if (!wheel || !mesh) continue;
|
|
meshIndex++;
|
|
this.wheelSphereDir.copy(wheel.direction);
|
|
const dirLen = this.wheelSphereDir.length();
|
|
if (dirLen > 1e-8) this.wheelSphereDir.multiplyScalar(1 / dirLen);
|
|
else this.wheelSphereDir.set(0, -1, 0);
|
|
mesh.position.copy(wheel.connectionPoint).addScaledVector(this.wheelSphereDir, wheel.suspensionLength);
|
|
mesh.scale.setScalar(Math.max(1e-4, wheel.radius));
|
|
}
|
|
}
|
|
|
|
/** 移除车轮碰撞球调试线框并释放几何 / 材质。 */
|
|
private disposeWheelSphereDebug(v: VehicleInstance): void {
|
|
const group = v.wheelSphereDebug;
|
|
if (!group) return;
|
|
group.removeFromParent();
|
|
const geos = new Set<THREE.BufferGeometry>();
|
|
const mats = new Set<THREE.Material>();
|
|
for (const child of group.children) {
|
|
const mesh = child as THREE.Mesh;
|
|
if (mesh.geometry) geos.add(mesh.geometry);
|
|
const mat = mesh.material;
|
|
if (Array.isArray(mat)) for (const m of mat) mats.add(m);
|
|
else if (mat) mats.add(mat);
|
|
}
|
|
for (const g of geos) g.dispose();
|
|
for (const m of mats) m.dispose();
|
|
v.wheelSphereDebug = undefined;
|
|
}
|
|
|
|
// 销毁全部车辆
|
|
destroy() {
|
|
for (const v of this.list) {
|
|
if (v.meshColliderId != null) this.ctrl.removeCollider(v.meshColliderId);
|
|
if (v.chassisColliderId != null) this.ctrl.removeCollider(v.chassisColliderId);
|
|
for (const id of v.wheelColliderIds ?? []) this.ctrl.removeCollider(id);
|
|
this.disposeWheelSphereDebug(v);
|
|
this.ctrl.scene.remove(v.vehicleGroup);
|
|
v.destroyVehicleController?.();
|
|
}
|
|
this.list = [];
|
|
this.active = null;
|
|
}
|
|
|
|
// 清除活动车辆的驱动力
|
|
stopActive() {
|
|
if (this.active) {
|
|
this.applyParkingBrake(this.active, this.ctrl.getCurrentDelta() || 1 / 60);
|
|
}
|
|
}
|
|
|
|
// 对无人车辆施加四轮驻车制动
|
|
private applyParkingBrake(v: VehicleInstance, delta: number) {
|
|
const wheelCount = Math.max(1, v.vehicleController.numWheels());
|
|
const brake = v.chassisBody.mass() * v.deceleration / wheelCount * delta;
|
|
for (let i = 0; i < wheelCount; i++) {
|
|
v.vehicleController.setWheelEngineForce(i, 0);
|
|
v.vehicleController.setWheelBrake(i, brake);
|
|
v.vehicleController.setWheelSideFrictionStiffness(i, v.sideFrictionStiffness);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 运行时修改底盘盒底相对轮胎触地点的高度。
|
|
* 重定底盘中心的同时反向平移车模和车轮,使车辆外观及轮胎触地点保持原位。
|
|
*/
|
|
setClearance(v: VehicleInstance, clearance: number): void {
|
|
if (!v || !Number.isFinite(clearance) || v.scale <= 0) return;
|
|
|
|
const sy = Math.max(1e-3, v.chassisSizeScaleY);
|
|
const minHeight = 1e-3;
|
|
const maxClearance = Math.max(
|
|
0,
|
|
v.chassisTopClearance - minHeight / (v.scale * sy),
|
|
);
|
|
const nextClearance = THREE.MathUtils.clamp(clearance, 0, maxClearance);
|
|
const previousClearance = v.chassisClearance;
|
|
if (Math.abs(nextClearance - previousClearance) < 1e-8) return;
|
|
|
|
const oldHalfY = v.halfExtents.y;
|
|
const newHalfY = Math.max(
|
|
minHeight * 0.5,
|
|
(v.chassisTopClearance - nextClearance) * v.scale * sy * 0.5,
|
|
);
|
|
// 底盘中心相对轮胎触地点的位置 = clearance + 碰撞盒半高。
|
|
const centerShift = (nextClearance - previousClearance) * v.scale
|
|
+ (newHalfY - oldHalfY);
|
|
|
|
const nextHalfExtents = v.halfExtents.clone();
|
|
nextHalfExtents.y = newHalfY;
|
|
v.chassisClearance = nextClearance;
|
|
this.applyChassisShape(v, nextHalfExtents, centerShift);
|
|
}
|
|
|
|
/** 运行时修改底盘碰撞盒三轴尺寸比例;Y 轴仍以盒底为缩放锚点。 */
|
|
setChassisSizeScale(
|
|
v: VehicleInstance,
|
|
sizeScale: { x?: number; y?: number; z?: number },
|
|
): void {
|
|
if (!v || v.scale <= 0) return;
|
|
|
|
const oldSx = Math.max(1e-3, v.chassisSizeScaleX);
|
|
const oldSy = Math.max(1e-3, v.chassisSizeScaleY);
|
|
const oldSz = Math.max(1e-3, v.chassisSizeScaleZ);
|
|
const sx = Math.max(1e-3, sizeScale.x ?? oldSx);
|
|
const sy = Math.max(1e-3, sizeScale.y ?? oldSy);
|
|
const sz = Math.max(1e-3, sizeScale.z ?? oldSz);
|
|
if (
|
|
Math.abs(sx - oldSx) < 1e-8
|
|
&& Math.abs(sy - oldSy) < 1e-8
|
|
&& Math.abs(sz - oldSz) < 1e-8
|
|
) return;
|
|
|
|
const nextHalfExtents = new THREE.Vector3(
|
|
v.halfExtents.x / oldSx * sx,
|
|
Math.max(
|
|
5e-4,
|
|
(v.chassisTopClearance - v.chassisClearance) * v.scale * sy * 0.5,
|
|
),
|
|
v.halfExtents.z / oldSz * sz,
|
|
);
|
|
const centerShift = nextHalfExtents.y - v.halfExtents.y;
|
|
v.chassisSizeScaleX = sx;
|
|
v.chassisSizeScaleY = sy;
|
|
v.chassisSizeScaleZ = sz;
|
|
this.applyChassisShape(v, nextHalfExtents, centerShift);
|
|
}
|
|
|
|
/** 同步底盘尺寸、车轮硬点、视觉原点和两套碰撞表示。 */
|
|
private applyChassisShape(
|
|
v: VehicleInstance,
|
|
nextHalfExtents: THREE.Vector3,
|
|
centerShift: number,
|
|
): void {
|
|
const shiftsCenter = Math.abs(centerShift) >= 1e-8;
|
|
|
|
if (shiftsCenter) {
|
|
for (const child of v.vehicleGroup.children) {
|
|
if (
|
|
child === v.physicsBoxMesh
|
|
|| child === v.wheelRayDebug
|
|
|| child === v.wheelTravelDebug
|
|
|| child === v.wheelSphereDebug
|
|
|| child === this.ctrl.playerCapsule
|
|
) continue;
|
|
child.position.y -= centerShift;
|
|
}
|
|
|
|
const wheelCount = v.vehicleController.numWheels();
|
|
for (let i = 0; i < wheelCount; i++) {
|
|
const wheel = v.vehicleController.wheelAt(i);
|
|
if (!wheel) continue;
|
|
this.scratchLocal.copy(wheel.connectionPoint);
|
|
this.scratchLocal.y -= centerShift;
|
|
v.vehicleController.setWheelChassisConnectionPointCs(i, this.scratchLocal);
|
|
}
|
|
|
|
v.driverSeatPosition.y -= centerShift / v.scale;
|
|
|
|
// 移动底盘中心以抵消本地内容位移,车身与轮胎在世界空间保持不动。
|
|
this.scratchUp.set(0, centerShift, 0).applyQuaternion(v.chassisBody.quaternion);
|
|
v.chassisBody.position.add(this.scratchUp);
|
|
v.vehicleGroup.position.copy(v.chassisBody.position);
|
|
v.vehicleGroup.quaternion.copy(v.chassisBody.quaternion);
|
|
v.vehicleGroup.updateMatrixWorld(true);
|
|
|
|
this.ctrl.translateKinematicColliderContent(
|
|
v.vehicleGroup,
|
|
this.scratchLocal.set(0, -centerShift, 0),
|
|
);
|
|
}
|
|
|
|
v.halfExtents.copy(nextHalfExtents);
|
|
v.chassisBody.setHalfExtents(v.halfExtents);
|
|
|
|
const chassisCol = v.chassisColliderId != null
|
|
? this.ctrl.collisionWorld.get(v.chassisColliderId)
|
|
: undefined;
|
|
if (chassisCol?.shape.kind === "box") {
|
|
chassisCol.shape.halfExtents.copy(v.halfExtents);
|
|
}
|
|
|
|
if (v.physicsBoxMesh) {
|
|
v.physicsBoxMesh.geometry.dispose();
|
|
v.physicsBoxMesh.geometry = new THREE.BoxGeometry(
|
|
v.halfExtents.x * 2,
|
|
v.halfExtents.y * 2,
|
|
v.halfExtents.z * 2,
|
|
);
|
|
v.physicsBoxMesh.scale.set(1, 1, 1);
|
|
}
|
|
if (v.wheelSphereDebug) this.syncWheelSphereDebug(v);
|
|
if (this.active === v && this.ctrl.controllerMode === 1) {
|
|
this.ctrl.syncMountedPlayer(v);
|
|
}
|
|
}
|
|
|
|
// 解除四轮驻车制动
|
|
private releaseParkingBrake(v: VehicleInstance) {
|
|
for (let i = 0; i < v.vehicleController.numWheels(); i++) v.vehicleController.setWheelBrake(i, 0);
|
|
}
|
|
|
|
/**
|
|
* 运行时等比缩放车辆。
|
|
* 保持 vehicleGroup.scale = 1,只缩放子节点与物理尺寸,避免座椅/下车点与 halfExtents 双重缩放。
|
|
* 悬挂刚度、阻尼、摩擦滑移不随 scale 变。
|
|
*/
|
|
setScale(v: VehicleInstance, newScale: number): void {
|
|
if (!v || newScale <= 0) return;
|
|
const prev = v.scale > 0 ? v.scale : 1;
|
|
const ratio = newScale / prev;
|
|
if (Math.abs(ratio - 1) < 1e-12) {
|
|
v.scale = newScale;
|
|
return;
|
|
}
|
|
|
|
const { chassisBody, vehicleController } = v;
|
|
const n = vehicleController.numWheels();
|
|
|
|
// 缩放前用局部轮底估算,缩放后抬/降底盘使胎底高度基本不变
|
|
let localBottom = Infinity;
|
|
for (let i = 0; i < n; i++) {
|
|
const wheel = vehicleController.wheelAt(i);
|
|
if (!wheel) continue;
|
|
localBottom = Math.min(localBottom, wheel.connectionPoint.y - wheel.radius);
|
|
}
|
|
if (!Number.isFinite(localBottom)) localBottom = -v.halfExtents.y;
|
|
|
|
for (const child of v.vehicleGroup.children) {
|
|
if (child === v.wheelRayDebug || child === v.wheelTravelDebug || child === v.wheelSphereDebug) continue;
|
|
child.position.multiplyScalar(ratio);
|
|
child.scale.multiplyScalar(ratio);
|
|
}
|
|
|
|
v.halfExtents.multiplyScalar(ratio);
|
|
v.size.l *= ratio;
|
|
v.size.w *= ratio;
|
|
v.size.h *= ratio;
|
|
v.maxSpeed *= ratio;
|
|
v.acceleration *= ratio;
|
|
v.deceleration *= ratio;
|
|
v.scale = newScale;
|
|
|
|
chassisBody.setHalfExtents(v.halfExtents);
|
|
chassisBody.setMass(chassisBody.mass() * ratio * ratio * ratio);
|
|
chassisBody.gravityScale = newScale;
|
|
chassisBody.linearVelocity.multiplyScalar(ratio);
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const wheel = vehicleController.wheelAt(i);
|
|
if (!wheel) continue;
|
|
vehicleController.setWheelChassisConnectionPointCs(i, wheel.connectionPoint.multiplyScalar(ratio));
|
|
vehicleController.setWheelRadius(i, wheel.radius * ratio);
|
|
vehicleController.setWheelSuspensionRestLength(i, wheel.restLength * ratio);
|
|
vehicleController.setWheelMaxSuspensionTravel(i, wheel.maxSuspensionTravel * ratio);
|
|
wheel.suspensionLength *= ratio;
|
|
wheel.visualLength *= ratio;
|
|
}
|
|
|
|
const chassisCol = v.chassisColliderId != null
|
|
? this.ctrl.collisionWorld.get(v.chassisColliderId)
|
|
: undefined;
|
|
if (chassisCol?.shape.kind === "box") {
|
|
chassisCol.shape.halfExtents.copy(v.halfExtents);
|
|
}
|
|
|
|
for (const id of v.wheelColliderIds ?? []) {
|
|
const col = this.ctrl.collisionWorld.get(id);
|
|
if (col?.shape.kind !== "sphere") continue;
|
|
const data = col.userData as VehicleWheelColliderUserData | undefined;
|
|
const wheel = data != null ? v.vehicleController.wheelAt(data.wheelIndex) : undefined;
|
|
if (wheel) col.shape.radius = Math.max(1e-4, wheel.radius);
|
|
}
|
|
|
|
if (v.wheelSphereDebug) this.syncWheelSphereDebug(v);
|
|
|
|
// 外观 mesh 碰撞在加载时烘焙,用 contentScale 对齐子节点缩放
|
|
this.ctrl.scaleKinematicColliderContent(v.vehicleGroup, ratio);
|
|
|
|
chassisBody.position.y += localBottom * (1 - ratio);
|
|
v.vehicleGroup.position.copy(chassisBody.position);
|
|
v.vehicleGroup.quaternion.copy(chassisBody.quaternion);
|
|
v.vehicleGroup.updateMatrixWorld(true);
|
|
|
|
if (this.active === v && this.ctrl.controllerMode === 1) {
|
|
this.ctrl.syncMountedPlayer(v);
|
|
}
|
|
}
|
|
|
|
/** 将列表中全部车辆缩放到同一绝对 scale。 */
|
|
setScaleAll(newScale: number): void {
|
|
for (const v of this.list) this.setScale(v, newScale);
|
|
}
|
|
|
|
private capsuleRadius(): number {
|
|
return this.ctrl.playerCapsule?.capsuleInfo?.radius ?? 0;
|
|
}
|
|
|
|
private capsuleHeight(): number {
|
|
const info = this.ctrl.playerCapsule?.capsuleInfo;
|
|
if (!info) return 0;
|
|
const sy = this.ctrl.playerCapsule.scale.y || 1;
|
|
return (-info.segment.end.y) * sy + 2 * info.radius;
|
|
}
|
|
}
|