chore(controller): three-player-controller als Ordner statt Submodul
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>
This commit is contained in:
1 parent
780e246fe5
commit
fb38652e7f
153 files changed
+42561
-4
No files matched your search
@@ -0,0 +1,315 @@
|
||||
import * as THREE from "three";
|
||||
import type { playerController } from "../PlayerController";
|
||||
|
||||
export class AnimationSystem {
|
||||
private ctrl: playerController; // 主控制器引用
|
||||
|
||||
mixer?: THREE.AnimationMixer; // 动画混合器
|
||||
mixerCb?: (ev: any) => void; // 完成事件回调
|
||||
actions?: Map<string, THREE.AnimationAction>; // 动作映射表
|
||||
state!: THREE.AnimationAction; // 当前播放状态
|
||||
sets = new Map<string, Map<string, THREE.AnimationAction>>(); // 动作集合组
|
||||
currentLocomotionSet: string | null = null; // 当前激活的动作集合名
|
||||
clips: THREE.AnimationClip[] = []; // 原始动画片段
|
||||
hasThreePartJump = false; // 是否使用三段跳跃动画
|
||||
isOverrideAnimationPlaying = false; // 动画锁,用于防止覆盖型动画被移动动画打断
|
||||
private overrideInputSnapshot: Record<string, any> | null = null; // 覆盖动画播放时的输入快照,用于检测打断
|
||||
|
||||
constructor(ctrl: playerController) {
|
||||
this.ctrl = ctrl;
|
||||
}
|
||||
|
||||
// 按名切换动画
|
||||
playByName(name: string, fade = 0.18) {
|
||||
if (!this.actions) return;
|
||||
const next = this.actions.get(name);
|
||||
// 如果动画不存在,或已在播放,则忽略
|
||||
if (!next || this.state === next) return;
|
||||
|
||||
const prev = this.state;
|
||||
next.reset();
|
||||
next.setEffectiveWeight(1);
|
||||
next.paused = false;
|
||||
|
||||
next.play();
|
||||
// 平滑过渡动画
|
||||
if (prev && prev !== next) { prev.fadeOut(fade); next.fadeIn(fade); }
|
||||
else next.fadeIn(fade);
|
||||
|
||||
this.state = next;
|
||||
this.ctrl.onAnimationChange?.(name, next);
|
||||
}
|
||||
|
||||
// 注册自定义动画
|
||||
register(key: string, clipName: string, opts?: {
|
||||
loop?: boolean;
|
||||
timeScale?: number;
|
||||
duration?: number;
|
||||
clampWhenFinished?: boolean;
|
||||
onFinished?: () => void;
|
||||
}) {
|
||||
if (!this.mixer || !this.actions) return;
|
||||
const clip = this.clips.find(c => c.name === clipName);
|
||||
if (!clip) { console.warn(`找不到 "${clipName}" 动画`); return; }
|
||||
|
||||
const action = this.mixer.clipAction(clip);
|
||||
// duration 优先于 timeScale,如果指定了 duration,则据此计算 timeScale
|
||||
const timeScale = opts?.duration ? clip.duration / opts.duration : (opts?.timeScale ?? 1);
|
||||
action.setLoop(opts?.loop === false ? THREE.LoopOnce : THREE.LoopRepeat, Infinity);
|
||||
action.clampWhenFinished = opts?.clampWhenFinished ?? false;
|
||||
action.setEffectiveTimeScale(timeScale);
|
||||
action.enabled = true;
|
||||
action.setEffectiveWeight(0);
|
||||
this.actions.set(key, action);
|
||||
|
||||
// 如果有 onFinished 回调,则监听动画完成事件
|
||||
if (opts?.onFinished) {
|
||||
this.mixer.addEventListener("finished", (ev: any) => {
|
||||
if (ev.action === action) opts.onFinished!();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 注册移动动作组
|
||||
registerLocomotionSet(setName: string, map: Partial<Record<"idle" | "walking" | "walking_backward" | "running" | "jumping" | "flyidle" | "flying", string>>) {
|
||||
if (!this.mixer) return;
|
||||
const set = new Map<string, THREE.AnimationAction>();
|
||||
for (const [key, clipName] of Object.entries(map) as [string, string][]) {
|
||||
const clip = this.clips.find(c => c.name === clipName);
|
||||
if (!clip) { console.warn(`registerLocomotionSet: 找不到 "${clipName}"`); continue; }
|
||||
const action = this.mixer.clipAction(clip);
|
||||
// 跳跃动画特殊处理:只播放一次
|
||||
if (key === "jumping") {
|
||||
action.setLoop(THREE.LoopOnce, 1);
|
||||
action.clampWhenFinished = true;
|
||||
action.setEffectiveTimeScale(1.2);
|
||||
} else {
|
||||
action.setLoop(THREE.LoopRepeat, Infinity);
|
||||
action.setEffectiveTimeScale(1);
|
||||
}
|
||||
action.enabled = true;
|
||||
action.setEffectiveWeight(0);
|
||||
set.set(key, action);
|
||||
}
|
||||
this.sets.set(setName, set);
|
||||
}
|
||||
|
||||
// 切换移动动作组
|
||||
switchLocomotionSet(setName: string, fade = 0.18) {
|
||||
if (!this.actions) return;
|
||||
const set = this.sets.get(setName);
|
||||
if (!set) { console.warn(`switchLocomotionSet: 未找到集合 "${setName}"`); return; }
|
||||
this.currentLocomotionSet = setName;
|
||||
for (const [key, newAction] of set.entries()) {
|
||||
const oldAction = this.actions.get(key);
|
||||
if (oldAction === newAction) continue;
|
||||
// 将旧动作淡出
|
||||
if (oldAction) oldAction.fadeOut(fade);
|
||||
// 替换当前动作表中的动作为新集合中的动作
|
||||
this.actions.set(key, newAction);
|
||||
// 如果正在播放的动画被替换,则立即切换到新动画
|
||||
if (this.state === oldAction) {
|
||||
newAction.reset();
|
||||
newAction.setEffectiveWeight(1);
|
||||
newAction.fadeIn(fade);
|
||||
newAction.play();
|
||||
this.state = newAction;
|
||||
this.ctrl.onAnimationChange?.(key, newAction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 播放已注册动画
|
||||
play(key: string, opts?: { fade?: number; force?: boolean; returnToPrev?: boolean }) {
|
||||
if (!this.actions) return;
|
||||
const action = this.actions.get(key);
|
||||
if (!action) { console.warn(`playAnimation: "${key}" 未注册`); return; }
|
||||
|
||||
// 如果是播放一次的动画,则设置动画锁
|
||||
if (action.loop === THREE.LoopOnce) {
|
||||
// 设置动画锁
|
||||
this.isOverrideAnimationPlaying = true;
|
||||
|
||||
// 记录当前完整输入状态快照
|
||||
this.overrideInputSnapshot = { ...this.ctrl.input };
|
||||
|
||||
// 设置动画播放完毕后的自动解锁
|
||||
const onFinish = (e: any) => {
|
||||
if (e.action === action) {
|
||||
// 仅当锁仍然激活时才解锁
|
||||
if (this.isOverrideAnimationPlaying && this.overrideInputSnapshot) {
|
||||
this.isOverrideAnimationPlaying = false;
|
||||
this.overrideInputSnapshot = null;
|
||||
}
|
||||
this.mixer!.removeEventListener('finished', onFinish);
|
||||
}
|
||||
};
|
||||
this.mixer!.addEventListener('finished', onFinish);
|
||||
}
|
||||
|
||||
if (opts?.force) action.reset();
|
||||
|
||||
// 记录初始动画状态以便返回
|
||||
const prevState = opts?.returnToPrev ? this.state : null;
|
||||
// 播放目标动画
|
||||
this.playByName(key, opts?.fade ?? 0.18);
|
||||
|
||||
// 如果设置 returnToPrev,则在动画播放完毕后返回之前的动画状态
|
||||
if (opts?.returnToPrev && prevState && this.mixer) {
|
||||
// 存储当前动画状态
|
||||
const action = this.actions.get(key)!;
|
||||
const fade = opts?.fade ?? 0.18;
|
||||
// 定义一个一次性的事件处理器
|
||||
const handler = (ev: any) => {
|
||||
if (ev.action === action && this.state === action) {
|
||||
this.mixer!.removeEventListener("finished", handler);
|
||||
const cur = this.state;
|
||||
// 停止当前动画
|
||||
cur.stop();
|
||||
// 重置前一个状态
|
||||
prevState.reset();
|
||||
// 将权重设置为1并播放前一个状态动画
|
||||
prevState.setEffectiveWeight(1);
|
||||
prevState.play();
|
||||
// 更新状态
|
||||
this.state = prevState;
|
||||
this.ctrl.onAnimationChange?.(prevState.getClip().name, prevState);
|
||||
}
|
||||
};
|
||||
// 监听动画完成事件
|
||||
this.mixer.addEventListener("finished", handler);
|
||||
}
|
||||
}
|
||||
|
||||
// 触发跳跃动画(统一入口)
|
||||
startJump(inAir = false) {
|
||||
// 根据是否配置了三段跳跃,播放不同动画
|
||||
if (this.hasThreePartJump) {
|
||||
this.playByName(inAir ? "jumpLoop" : "jumpStart");
|
||||
} else {
|
||||
this.playByName("jumping");
|
||||
}
|
||||
}
|
||||
|
||||
// 离地时触发 jumpLoop(三段模式专用)
|
||||
onBecomeAirborne() {
|
||||
if (!this.hasThreePartJump) return;
|
||||
const s = this.state;
|
||||
const a = this.actions;
|
||||
// 如果当前已经在跳跃动画中,则不打断
|
||||
if (s === a?.get("jumpStart") || s === a?.get("jumpLoop") || s === a?.get("jumpEnd")) return;
|
||||
this.playByName("jumpLoop");
|
||||
}
|
||||
|
||||
// 落地时触发 jumpEnd(三段模式专用)
|
||||
onLand() {
|
||||
if (!this.hasThreePartJump) return;
|
||||
const s = this.state;
|
||||
const a = this.actions;
|
||||
// 只有在起跳或跳跃循环中才触发落地
|
||||
if (s === a?.get("jumpStart") || s === a?.get("jumpLoop")) {
|
||||
const { fwd, bkd, lft, rgt } = this.ctrl.input;
|
||||
if (fwd || bkd || lft || rgt) {
|
||||
this.setAnimationByPressed();
|
||||
return;
|
||||
}
|
||||
this.playByName("jumpEnd");
|
||||
}
|
||||
}
|
||||
|
||||
// 是否处于任意跳跃动画中(用于防止在跳跃动画播放时重复起跳)
|
||||
isJumping(): boolean {
|
||||
const s = this.state;
|
||||
const a = this.actions;
|
||||
if (!a) return false;
|
||||
return s === a.get("jumping") || s === a.get("jumpStart") ||
|
||||
s === a.get("jumpLoop") || s === a.get("jumpEnd");
|
||||
}
|
||||
|
||||
// 获取当前动画名
|
||||
getCurrentName(): string | null {
|
||||
return this.state?.getClip()?.name ?? null;
|
||||
}
|
||||
|
||||
// 更新所有混合器
|
||||
updateMixers(delta: number) {
|
||||
this.mixer?.update(delta);
|
||||
}
|
||||
|
||||
// 按键状态触发动画
|
||||
setAnimationByPressed() {
|
||||
// 检查并处理覆盖动画中断逻辑
|
||||
if (this.isOverrideAnimationPlaying) {
|
||||
const currentInput = this.ctrl.input as Record<string, any>;
|
||||
const snapshot = this.overrideInputSnapshot;
|
||||
let inputChanged = false;
|
||||
|
||||
if (snapshot) {
|
||||
// 遍历快照中的所有键,与当前输入状态进行比较
|
||||
for (const key in snapshot) {
|
||||
if (snapshot[key] !== currentInput[key]) {
|
||||
inputChanged = true;
|
||||
break; // 不匹配,中断
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果输入状态发生变化,则解除动画锁,并继续执行后续的移动动画逻辑
|
||||
if (inputChanged) {
|
||||
this.isOverrideAnimationPlaying = false;
|
||||
this.overrideInputSnapshot = null;
|
||||
} else {
|
||||
// 如果输入状态未变,则保持覆盖动画,不执行移动动画
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 未启用缩放时恢复默认距离;飞行加速时拉远;启用缩放后保留滚轮选择的 maxDist。
|
||||
this.ctrl.cam.applyFlySprintMaxDist();
|
||||
|
||||
if (this.ctrl.controllerMode === 1) return;
|
||||
|
||||
const { fwd, bkd, lft, rgt, shift, space } = this.ctrl.input;
|
||||
|
||||
// 飞行状态下的动画逻辑
|
||||
if (this.ctrl.isFlying) {
|
||||
if (fwd) {
|
||||
if (shift) {
|
||||
this.playByName("flying");
|
||||
} else {
|
||||
this.playByName("flyHoverForward");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (bkd) { this.playByName("flyHoverBack"); return; }
|
||||
if (lft) { this.playByName("flyHoverLeft"); return; }
|
||||
if (rgt) { this.playByName("flyHoverRight"); return; }
|
||||
if (space) { this.playByName("flyHoverUp"); return; }
|
||||
// 无任何操作时,播放悬停动画
|
||||
this.playByName("flyidle");
|
||||
return;
|
||||
}
|
||||
|
||||
// 地面状态下的动画逻辑
|
||||
if (this.ctrl.playerIsOnGround) {
|
||||
// 无移动输入时等待落地动画播完;有移动输入则立即切换移动动画
|
||||
if (
|
||||
this.hasThreePartJump &&
|
||||
this.state === this.actions?.get("jumpEnd") &&
|
||||
!fwd && !bkd && !lft && !rgt
|
||||
) return;
|
||||
// 无方向键输入,播放站立动画
|
||||
if (!fwd && !bkd && !lft && !rgt) { this.playByName("idle"); return; }
|
||||
// 向前走或跑
|
||||
if (fwd) { this.playByName(shift ? "running" : "walking"); return; }
|
||||
// 第三人称下,左、右、后退也播放走/跑动画(模型会自动转向)
|
||||
if (!this.ctrl.isFirstPerson && (lft || rgt || bkd)) {
|
||||
this.playByName(shift ? "running" : "walking"); return;
|
||||
}
|
||||
// 第一人称下的平移和后退
|
||||
if (lft) { this.playByName("left_walking"); return; }
|
||||
if (rgt) { this.playByName("right_walking"); return; }
|
||||
if (bkd) { this.playByName("walking_backward"); return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import * as THREE from "three";
|
||||
import type { playerController } from "../PlayerController";
|
||||
|
||||
export class CameraSystem {
|
||||
private ctrl: playerController; // 主控制器引用
|
||||
|
||||
collisionLerp = 0.18; // 碰撞插值速度
|
||||
epsilon = 35; // 安全距离偏移
|
||||
minDist = 100; // 最小相机距离
|
||||
maxDist = 440; // 最大相机距离
|
||||
originMaxDist = 440; // 配置的默认最远距离
|
||||
sensitivity = 5; // 鼠标灵敏度
|
||||
mouseMode: 0 | 1 | 2 | 3 | 4 | 5 = 1; // 鼠标控制模式
|
||||
zoomEnabled = false; // 是否允许缩放
|
||||
lookAtHeightRatio = 0.8; // 第三人称看向点高度比例(0=底部,1=顶部)
|
||||
overShoulderOffsetRatio = 0.2; // 第三人称相机过肩视角横向偏移比例
|
||||
|
||||
private lookAtPoint = new THREE.Vector3(); // 预分配的看向点向量
|
||||
|
||||
enableSpringCamera = false;
|
||||
springCameraTime = 0.05;
|
||||
vehicleTurnLerp = 0.01;
|
||||
private _springVelocity = new THREE.Vector3();
|
||||
private _springResult = new THREE.Vector3();
|
||||
private zoomWheelElement: HTMLElement | SVGElement | null = null;
|
||||
private readonly boundZoomWheel: EventListener = (event) => this.onZoomWheel(event as WheelEvent);
|
||||
private _flySprintMaxDistBoost = false;
|
||||
private _flySprintSavedMaxDist = 0;
|
||||
|
||||
raycaster = new THREE.Raycaster(new THREE.Vector3(), new THREE.Vector3()); // 相机碰撞射线
|
||||
centerRay = new THREE.Raycaster(); // 屏幕中心射线
|
||||
centerMouse = new THREE.Vector2(); // 屏幕中心坐标
|
||||
playerToCam = new THREE.Vector3(); // 玩家到相机向量
|
||||
|
||||
constructor(ctrl: playerController) {
|
||||
this.ctrl = ctrl;
|
||||
(this.raycaster as any).firstHitOnly = true;
|
||||
}
|
||||
|
||||
// 通用弹簧阻尼:把 controls.target 朝 dest 平滑跟随,返回本帧的目标点
|
||||
springTarget(dest: THREE.Vector3, delta: number): THREE.Vector3 {
|
||||
if (!this.enableSpringCamera) return dest;
|
||||
const cur = this.ctrl.controls.target;
|
||||
const v = this._springVelocity;
|
||||
const out = this._springResult;
|
||||
const smoothTime = Math.max(0.0001, this.springCameraTime);
|
||||
const omega = 2 / smoothTime;
|
||||
const x = omega * delta;
|
||||
const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
|
||||
const axes = ['x', 'y', 'z'] as const;
|
||||
for (const a of axes) {
|
||||
const change = cur[a] - dest[a];
|
||||
const temp = (v[a] + omega * change) * delta;
|
||||
v[a] = (v[a] - omega * temp) * exp;
|
||||
let o = dest[a] + (change + temp) * exp;
|
||||
if ((dest[a] - cur[a] > 0) === (o > dest[a])) {
|
||||
o = dest[a];
|
||||
v[a] = 0;
|
||||
}
|
||||
out[a] = o;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 第三人称相机看向点
|
||||
getLookAtPoint(): THREE.Vector3 {
|
||||
if (this.ctrl.controllerMode === 1 && this.ctrl.vehicle.active) {
|
||||
return this.lookAtPoint.copy(this.ctrl.vehicle.active.vehicleGroup.position);
|
||||
}
|
||||
const capsuleInfo = this.ctrl.playerCapsule.capsuleInfo;
|
||||
const r = capsuleInfo.radius;
|
||||
const sy = this.ctrl.playerCapsule.scale.y || 1;
|
||||
const totalH = (-capsuleInfo.segment.end.y) * sy + 2 * r;
|
||||
const y = this.ctrl.playerCapsule.position.y + r - totalH * (1 - this.lookAtHeightRatio);
|
||||
return this.lookAtPoint.copy(this.ctrl.playerCapsule.position).setY(y);
|
||||
}
|
||||
|
||||
// 设置越肩视角
|
||||
setOverShoulder(enable: boolean) {
|
||||
if (!enable || this.ctrl.controllerMode === 1) { this.ctrl.camera.clearViewOffset(); return; }
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
this.ctrl.camera.setViewOffset(w, h, w * this.overShoulderOffsetRatio, 0, w, h);
|
||||
}
|
||||
|
||||
// 切换视角模式
|
||||
changeView() {
|
||||
this.ctrl.onBeforeViewChange?.(this.ctrl.isFirstPerson);
|
||||
this.ctrl.isFirstPerson = !this.ctrl.isFirstPerson;
|
||||
if (this.ctrl.isFirstPerson) {
|
||||
// 切到第一人称,先对齐胶囊体朝向
|
||||
const playerFwd = new THREE.Vector3(0, 0, 1).applyQuaternion(this.ctrl.playerCapsule.quaternion);
|
||||
const flatDir = new THREE.Vector3(playerFwd.x, 0, playerFwd.z).normalize();
|
||||
if (flatDir.lengthSq() > 0.001) {
|
||||
const yAngle = Math.atan2(flatDir.x, flatDir.z);
|
||||
this.ctrl.playerCapsule.rotation.set(0, yAngle, 0);
|
||||
}
|
||||
this.setFirstPerson();
|
||||
this.setOverShoulder(false);
|
||||
} else {
|
||||
// 切到第三人称,放置相机到玩家背后
|
||||
this.ctrl.controls.enabled = true;
|
||||
this.ctrl.scene.attach(this.ctrl.camera);
|
||||
const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(this.ctrl.playerCapsule.quaternion);
|
||||
const angle = Math.atan2(dir.z, dir.x);
|
||||
const s = this.ctrl.playerModelConfig.scale;
|
||||
const rawOffset = new THREE.Vector3(Math.cos(angle) * 400 * s, 200 * s, Math.sin(angle) * 400 * s);
|
||||
this.ctrl.controls.target.copy(this.getLookAtPoint());
|
||||
this.ctrl.camera.position.copy(this.ctrl.controls.target).add(rawOffset.normalize().multiplyScalar(this.maxDist));
|
||||
// 滚轮由 CameraSystem 改写 maxDist,禁用自带的直接位移缩放。
|
||||
this.ctrl.controls.enableZoom = false;
|
||||
this.setOverShoulder(this.ctrl.enableOverShoulderView);
|
||||
}
|
||||
this.setPointerLock();
|
||||
this.ctrl.onViewChange?.(this.ctrl.isFirstPerson);
|
||||
}
|
||||
|
||||
// 进入第一人称
|
||||
setFirstPerson(vertAngle = 0) {
|
||||
this.ctrl.controls.enabled = false;
|
||||
const s = this.ctrl.playerModelConfig.scale;
|
||||
const sharedOffset = this.ctrl.playerModelConfig.firstPersonCameraOffset;
|
||||
// 有头骨节点挂到头骨,否则挂到胶囊体
|
||||
if (this.ctrl.playerModelHead) {
|
||||
const [x, y, z] = sharedOffset ?? [0, 10, 20];
|
||||
this.ctrl.playerModelHead.attach(this.ctrl.camera);
|
||||
this.ctrl.camera.position.set(x, y, z);
|
||||
} else {
|
||||
const [x, y, z] = sharedOffset ?? [0, 40, 30];
|
||||
this.ctrl.playerCapsule.attach(this.ctrl.camera);
|
||||
this.ctrl.camera.position.set(x * s, y * s, z * s);
|
||||
}
|
||||
this.ctrl.camera.rotation.set(
|
||||
THREE.MathUtils.clamp(vertAngle, -1.1, 1.4),
|
||||
Math.PI,
|
||||
0,
|
||||
);
|
||||
this.ctrl.controls.enableZoom = false;
|
||||
}
|
||||
|
||||
// 指针锁定控制
|
||||
setPointerLock() {
|
||||
const lockTarget = this.ctrl.controls.domElement;
|
||||
if (!lockTarget?.requestPointerLock) return;
|
||||
if (((this.mouseMode === 0 || this.mouseMode === 1 || this.mouseMode === 5) && !this.ctrl.isFirstPerson) || this.ctrl.isFirstPerson) {
|
||||
lockTarget.requestPointerLock();
|
||||
} else {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
}
|
||||
|
||||
// 初始相机位置
|
||||
setCamPos() {
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.ctrl.isFirstPerson) {
|
||||
const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(this.ctrl.playerCapsule.quaternion);
|
||||
const angle = Math.atan2(dir.z, dir.x);
|
||||
const s = this.ctrl.playerModelConfig.scale;
|
||||
const rawOffset = new THREE.Vector3(Math.cos(angle) * 400 * s, 200 * s, Math.sin(angle) * 400 * s);
|
||||
this.ctrl.controls.target.copy(this.getLookAtPoint());
|
||||
this.ctrl.camera.position.copy(this.ctrl.controls.target).add(rawOffset.normalize().multiplyScalar(this.maxDist));
|
||||
this.ctrl.controls.enableZoom = false;
|
||||
} else {
|
||||
this.setFirstPerson();
|
||||
}
|
||||
this.ctrl.camera.updateProjectionMatrix();
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化轨道控制
|
||||
initControls() {
|
||||
this.ctrl.controls.enableZoom = false;
|
||||
this.ctrl.controls.rotateSpeed = this.sensitivity * 0.05;
|
||||
this.ctrl.controls.maxPolarAngle = Math.PI;
|
||||
this.ctrl.controls.mouseButtons = { LEFT: 0, MIDDLE: 1, RIGHT: 2 };
|
||||
// 防止相机轨道半径归零穿越目标点
|
||||
this.ctrl.controls.minDistance = this.minDist;
|
||||
this.connectZoomWheel();
|
||||
}
|
||||
|
||||
/** 切换第三人称滚轮缩放;关闭时恢复配置的默认最远距离。 */
|
||||
setZoomEnabled(enabled: boolean) {
|
||||
this.zoomEnabled = enabled;
|
||||
this.ctrl.controls.enableZoom = false;
|
||||
this._flySprintMaxDistBoost = false;
|
||||
this.maxDist = enabled
|
||||
? Math.max(this.maxDist, this.minDist)
|
||||
: this.originMaxDist;
|
||||
}
|
||||
|
||||
/** 非弹簧第三人称飞行加速时拉远相机,否则恢复默认最远距离。 */
|
||||
applyFlySprintMaxDist(): void {
|
||||
const boost = !this.enableSpringCamera
|
||||
&& !this.ctrl.isFirstPerson
|
||||
&& this.ctrl.controllerMode === 0
|
||||
&& this.ctrl.isFlying
|
||||
&& this.ctrl.input.shift;
|
||||
|
||||
if (!this.zoomEnabled) {
|
||||
this._flySprintMaxDistBoost = false;
|
||||
this.maxDist = this.originMaxDist * (boost ? 2 : 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (boost && !this._flySprintMaxDistBoost) {
|
||||
this._flySprintSavedMaxDist = this.maxDist;
|
||||
this.maxDist *= 2;
|
||||
this._flySprintMaxDistBoost = true;
|
||||
} else if (!boost && this._flySprintMaxDistBoost) {
|
||||
this.maxDist = this._flySprintSavedMaxDist;
|
||||
this._flySprintMaxDistBoost = false;
|
||||
}
|
||||
}
|
||||
|
||||
clearFlySprintMaxDistBoost(): void {
|
||||
this._flySprintMaxDistBoost = false;
|
||||
}
|
||||
|
||||
/** 绑定到渲染画布,使普通鼠标与 Pointer Lock 状态共用同一滚轮入口。 */
|
||||
private connectZoomWheel() {
|
||||
const element = this.ctrl.controls.domElement;
|
||||
if (!element) return;
|
||||
if (this.zoomWheelElement === element) return;
|
||||
this.disconnectZoomWheel();
|
||||
element.addEventListener("wheel", this.boundZoomWheel, { passive: false });
|
||||
this.zoomWheelElement = element;
|
||||
}
|
||||
|
||||
private disconnectZoomWheel() {
|
||||
this.zoomWheelElement?.removeEventListener("wheel", this.boundZoomWheel);
|
||||
this.zoomWheelElement = null;
|
||||
}
|
||||
|
||||
/** 滚轮改变目标最远距离。 */
|
||||
private onZoomWheel(event: WheelEvent) {
|
||||
if (!this.zoomEnabled || this.ctrl.isFirstPerson || event.deltaY === 0) return;
|
||||
event.preventDefault();
|
||||
|
||||
// 与 OrbitControls 一致地归一化像素、行和页三种 wheel delta。
|
||||
let deltaY = event.deltaY;
|
||||
if (event.deltaMode === 1) deltaY *= 16;
|
||||
else if (event.deltaMode === 2) deltaY *= 100;
|
||||
if (event.ctrlKey) deltaY *= 10;
|
||||
|
||||
const scale = Math.pow(0.95, Math.abs(deltaY * 0.01));
|
||||
const nextDistance = deltaY < 0
|
||||
? this.maxDist * scale
|
||||
: this.maxDist / scale;
|
||||
this.maxDist = Math.max(nextDistance, this.minDist);
|
||||
}
|
||||
|
||||
// 重置轨道控制
|
||||
resetControls() {
|
||||
if (!this.ctrl.controls) return;
|
||||
this.disconnectZoomWheel();
|
||||
this.ctrl.controls.enabled = true;
|
||||
this.ctrl.controls.enablePan = true;
|
||||
this.ctrl.controls.maxPolarAngle = Math.PI / 2;
|
||||
this.ctrl.controls.rotateSpeed = 1;
|
||||
this.ctrl.controls.enableZoom = true;
|
||||
this.ctrl.controls.mouseButtons = { LEFT: 0, MIDDLE: 1, RIGHT: 2 };
|
||||
}
|
||||
|
||||
// 处理鼠标朝向
|
||||
setToward(dx: number, dy: number, speed: number) {
|
||||
this.ctrl.onTowardChange?.(dx, dy, speed);
|
||||
if (!this.ctrl.enableToward) return;
|
||||
const sens = this.sensitivity;
|
||||
if (this.ctrl.controllerMode === 0) {
|
||||
// 步行第一人称
|
||||
if (this.ctrl.isFirstPerson) {
|
||||
this.ctrl.playerCapsule.rotateY(-dx * speed * sens);
|
||||
this.ctrl.camera.rotation.x = THREE.MathUtils.clamp(
|
||||
this.ctrl.camera.rotation.x + (-dy * speed * sens),
|
||||
-Math.PI * (60 / 180), Math.PI * (80 / 180),
|
||||
);
|
||||
} else {
|
||||
// 步行第三人称
|
||||
this.orbit(this.getLookAtPoint(), -dx * speed * sens, -dy * speed * sens);
|
||||
}
|
||||
} else {
|
||||
const v = this.ctrl.vehicle.active;
|
||||
if (!v) return;
|
||||
// 载具第一人称
|
||||
if (this.ctrl.isFirstPerson) {
|
||||
this.ctrl.camera.rotation.y = THREE.MathUtils.clamp(this.ctrl.camera.rotation.y + (-dx * speed * sens), Math.PI * (3 / 4), Math.PI * (5 / 4));
|
||||
this.ctrl.camera.rotation.x = THREE.MathUtils.clamp(this.ctrl.camera.rotation.x + (-dy * speed * sens), 0, Math.PI * (1 / 3));
|
||||
} else {
|
||||
// 载具第三人称
|
||||
this.orbit(v.vehicleGroup.position, -dx * speed * sens, -dy * speed * sens);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 手动轨道旋转
|
||||
private orbit(target: THREE.Vector3, deltaX: number, deltaY: number) {
|
||||
const distance = this.ctrl.camera.position.distanceTo(target);
|
||||
const cur = this.ctrl.camera.position.clone().sub(target);
|
||||
let theta = Math.atan2(cur.x, cur.z) + deltaX;
|
||||
let phi = Math.acos(THREE.MathUtils.clamp(cur.y / distance, -1, 1)) + deltaY;
|
||||
// 限制仰角避免万向节死锁
|
||||
phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi));
|
||||
this.ctrl.camera.position.set(
|
||||
target.x + distance * Math.sin(phi) * Math.sin(theta),
|
||||
target.y + distance * Math.cos(phi),
|
||||
target.z + distance * Math.sin(phi) * Math.cos(theta),
|
||||
);
|
||||
this.ctrl.camera.lookAt(target);
|
||||
}
|
||||
|
||||
// 载具第三人称:弹簧跟随 + 射线避障 + 速度方向
|
||||
updateThirdPersonVehicle(delta: number) {
|
||||
const v = this.ctrl.vehicle.active;
|
||||
if (!v) return;
|
||||
|
||||
const lookTarget = this.springTarget(v.vehicleGroup.position, delta).clone();
|
||||
this.ctrl.camera.position.sub(this.ctrl.controls.target);
|
||||
this.ctrl.controls.target.copy(lookTarget);
|
||||
this.ctrl.camera.position.add(lookTarget);
|
||||
this.ctrl.controls.update();
|
||||
|
||||
const vehicleBaseDistance = Math.max(this.minDist, v.size.l * 0.8);
|
||||
const desiredDist = Math.max(this.maxDist, vehicleBaseDistance);
|
||||
this.updateWithRaycast(this.ctrl.controls.target, desiredDist);
|
||||
|
||||
if ((this.ctrl.input.fwd || this.ctrl.input.bkd) && v.followVehicleDirection) {
|
||||
const vel = v.chassisBody.linvel();
|
||||
if (Math.hypot(vel.x, vel.z) > 0.3) {
|
||||
const targetAngle = Math.atan2(-vel.x, -vel.z);
|
||||
const offX = this.ctrl.camera.position.x - this.ctrl.controls.target.x;
|
||||
const offZ = this.ctrl.camera.position.z - this.ctrl.controls.target.z;
|
||||
const radius = Math.hypot(offX, offZ);
|
||||
const curAngle = Math.atan2(offX, offZ);
|
||||
const diff = Math.atan2(Math.sin(targetAngle - curAngle), Math.cos(targetAngle - curAngle));
|
||||
const newAngle = curAngle + diff * this.vehicleTurnLerp;
|
||||
this.ctrl.camera.position.x = this.ctrl.controls.target.x + Math.sin(newAngle) * radius;
|
||||
this.ctrl.camera.position.z = this.ctrl.controls.target.z + Math.cos(newAngle) * radius;
|
||||
this.ctrl.controls.update();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 收集相机射线碰撞。开车时跳过当前车辆网格。
|
||||
private collectCameraHits() {
|
||||
const skipIds = this.ctrl.controllerMode === 1 && this.ctrl.vehicle.active?.meshColliderId != null
|
||||
? [this.ctrl.vehicle.active.meshColliderId]
|
||||
: undefined;
|
||||
const hits: THREE.Intersection[] = [];
|
||||
for (const mesh of this.ctrl.getColliderMeshes({ skipIds })) {
|
||||
const meshHits = this.raycaster.intersectObject(mesh, false);
|
||||
if (meshHits[0] && (!hits[0] || meshHits[0].distance < hits[0].distance)) {
|
||||
hits[0] = meshHits[0];
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
// 射线防穿墙
|
||||
updateWithRaycast(origin: THREE.Vector3, maxDist: number = this.maxDist, minDist = this.minDist) {
|
||||
this.playerToCam.subVectors(this.ctrl.camera.position, origin);
|
||||
const direction = this.playerToCam.clone().normalize();
|
||||
this.raycaster.set(origin, direction);
|
||||
this.raycaster.far = maxDist;
|
||||
|
||||
const hits = this.collectCameraHits();
|
||||
// 有遮挡:贴近安全距离
|
||||
if (hits.length > 0) {
|
||||
const safeDist = Math.max(hits[0].distance - this.epsilon, minDist);
|
||||
this.ctrl.camera.position.lerp(origin.clone().add(direction.multiplyScalar(safeDist)), this.collisionLerp);
|
||||
} else {
|
||||
// 无遮挡:尝试拉到最大距离
|
||||
this.raycaster.far = maxDist;
|
||||
const maxHits = this.collectCameraHits();
|
||||
const safeDist = maxHits.length > 0 ? Math.min(maxDist, maxHits[0].distance - this.epsilon) : maxDist;
|
||||
this.ctrl.camera.position.lerp(origin.clone().add(direction.multiplyScalar(safeDist)), this.collisionLerp);
|
||||
}
|
||||
}
|
||||
|
||||
// 屏幕中心检测
|
||||
getCenterHit(): THREE.Intersection | undefined {
|
||||
this.ctrl.camera.updateMatrixWorld();
|
||||
this.centerRay.setFromCamera(this.centerMouse, this.ctrl.camera);
|
||||
this.centerRay.layers.set(1);
|
||||
this.centerRay.layers.enable(2);
|
||||
|
||||
const meshes = this.ctrl.getColliderMeshes();
|
||||
const checkTargets = meshes.length
|
||||
? [...meshes, ...this.ctrl.scene.children]
|
||||
: this.ctrl.scene.children;
|
||||
const hits = this.centerRay.intersectObjects(checkTargets, true);
|
||||
hits.sort((a, b) => a.distance - b.distance);
|
||||
if (hits[0]) return hits[0];
|
||||
|
||||
// 无命中返回1000距离的虚拟点
|
||||
const fallbackPoint = this.centerRay.ray.at(1000, new THREE.Vector3());
|
||||
return {
|
||||
distance: 1000, point: fallbackPoint, object: this.ctrl.camera,
|
||||
uv: null as any, normal: null as any, face: null as any,
|
||||
faceIndex: null as any, instanceId: undefined,
|
||||
} as THREE.Intersection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import * as THREE from "three";
|
||||
import type { playerController } from "../PlayerController";
|
||||
import type { KinematicColliderEntry } from "../types";
|
||||
import { CHARACTER_QUERY_MASK } from "../collision/groups";
|
||||
import type { DynamicBody } from "../collision/DynamicBody";
|
||||
import type { DynamicGroundHit } from "./DynamicBodySystem";
|
||||
import type { PlayerDebug } from "./PlayerDebug";
|
||||
import {
|
||||
applyCapsuleCollision,
|
||||
createGroundProbeTemps,
|
||||
probeMeshGround,
|
||||
} from "../utils/capsuleCollision";
|
||||
|
||||
/** 体积地面传感区半径相对玩家胶囊半径的比例。 */
|
||||
const GROUND_SENSOR_RADIUS_RATIO = 0.4;
|
||||
/** 可作为支撑面的最小向上法线分量。 */
|
||||
const GROUND_SUPPORT_NORMAL_Y = 0.5;
|
||||
|
||||
/**
|
||||
* 人物胶囊移动:地面射线、分步推出、重力 snap、平台携带。
|
||||
* 不走刚体冲量核(位置推出式 CharacterController)。
|
||||
*/
|
||||
export class CharacterMovement {
|
||||
private dynamicSupportBody: DynamicBody | null = null;
|
||||
private dynamicSupportPrevPosition = new THREE.Vector3();
|
||||
private dynamicSupportPrevQuaternion = new THREE.Quaternion();
|
||||
private dynamicSupportInvQuaternion = new THREE.Quaternion();
|
||||
private dynamicSupportLocal = new THREE.Vector3();
|
||||
private dynamicSupportBefore = new THREE.Vector3();
|
||||
private dynamicSupportDelta = new THREE.Vector3();
|
||||
private groundProbeTemps = createGroundProbeTemps();
|
||||
private groundProbeStart = new THREE.Vector3();
|
||||
private groundProbeDown = new THREE.Vector3();
|
||||
private groundProbeOffset = new THREE.Vector3();
|
||||
private shapeGroundPoint = new THREE.Vector3();
|
||||
private shapeGroundNormal = new THREE.Vector3();
|
||||
private rayGroundNormal = new THREE.Vector3();
|
||||
private rayGroundNormalMatrix = new THREE.Matrix3();
|
||||
|
||||
constructor(
|
||||
private readonly ctrl: playerController,
|
||||
private readonly debug: PlayerDebug,
|
||||
) { }
|
||||
|
||||
/** 步行 / 飞行一帧:输入速度 → 落地 → 分步碰撞 → 朝向与相机。 */
|
||||
update(delta: number): void {
|
||||
const c = this.ctrl;
|
||||
// 载具模式只跑动画,移动由车辆系统负责
|
||||
if (c.controllerMode === 1) {
|
||||
c.clearGroundSupport();
|
||||
this.debug.clearGroundProbe();
|
||||
c.runAnimationPass(delta);
|
||||
return;
|
||||
}
|
||||
// —— 输入与目标速度 ——
|
||||
c.camera.getWorldDirection(c.camDir);
|
||||
const angle = 2 * Math.PI - (Math.atan2(c.camDir.z, c.camDir.x) + Math.PI / 2);
|
||||
|
||||
const moveAxes = c.input.getMoveAxes();
|
||||
c.moveDir.copy(c.DIR_RGT).multiplyScalar(moveAxes.x).addScaledVector(c.DIR_FWD, moveAxes.y);
|
||||
if (c.isFlying) {
|
||||
if (c.input.fwd || moveAxes.isAnalog) c.moveDir.copy(c.camDir);
|
||||
if (c.input.space) c.moveDir.y += 1;
|
||||
c.curPlayerSpeed = c.input.shift ? c.playerFlySpeed * 2 : c.playerFlySpeed;
|
||||
} else {
|
||||
c.curPlayerSpeed = c.input.shift ? c.playerRunSpeed : c.playerSpeed;
|
||||
}
|
||||
|
||||
c.moveDir.normalize();
|
||||
// 步行:相对相机水平朝向;飞行且镜头前进时已用 camDir,不再绕 Y 转
|
||||
if (!c.isFlying || (!moveAxes.isAnalog && !c.input.fwd)) {
|
||||
c.moveDir.applyAxisAngle(c.upVector, angle);
|
||||
}
|
||||
|
||||
// 水平速度向目标加速 / 无输入时减速
|
||||
const accelStep = c.playerAcceleration * c.decelBase * delta;
|
||||
const decelStep = c.playerDeceleration * c.decelBase * delta;
|
||||
const targetX = c.moveDir.x * c.curPlayerSpeed;
|
||||
const targetZ = c.moveDir.z * c.curPlayerSpeed;
|
||||
const diffX = targetX - c.playerVelocity.x;
|
||||
const diffZ = targetZ - c.playerVelocity.z;
|
||||
const hasXZInput = c.moveDir.x !== 0 || c.moveDir.z !== 0;
|
||||
const xzDiffLen = Math.hypot(diffX, diffZ);
|
||||
if (xzDiffLen > 0) {
|
||||
const xzApplied = Math.min(xzDiffLen, hasXZInput ? accelStep : decelStep);
|
||||
c.playerVelocity.x += (diffX / xzDiffLen) * xzApplied;
|
||||
c.playerVelocity.z += (diffZ / xzDiffLen) * xzApplied;
|
||||
}
|
||||
if (c.isFlying) {
|
||||
const targetY = c.moveDir.y * c.curPlayerSpeed;
|
||||
const diffY = targetY - c.playerVelocity.y;
|
||||
c.playerVelocity.y += Math.sign(diffY) * Math.min(Math.abs(diffY), c.moveDir.y !== 0 ? accelStep : decelStep);
|
||||
}
|
||||
|
||||
// —— 脚下射线:静态 / 运动学网格 / 动态体,取最高命中 ——
|
||||
c.playerCapsule.updateMatrixWorld(true);
|
||||
c.groundRaycaster.ray.origin.copy(c.playerCapsule.position);
|
||||
let bestHit: THREE.Intersection | undefined;
|
||||
let hitEntry: KinematicColliderEntry | null = null;
|
||||
for (const col of c.collisionWorld.query({ mask: CHARACTER_QUERY_MASK })) {
|
||||
if (col.shape.kind !== "mesh") continue;
|
||||
const hits = c.groundRaycaster.intersectObject(col.shape.mesh, false);
|
||||
if (hits.length > 0 && (!bestHit || hits[0].point.y > bestHit.point.y)) {
|
||||
bestHit = hits[0];
|
||||
hitEntry = col.motion === "kinematic" ? col.userData as KinematicColliderEntry : null;
|
||||
}
|
||||
}
|
||||
let dynamicHit = c.dynamics.raycastGround(c.playerCapsule.position);
|
||||
if (dynamicHit && bestHit && dynamicHit.point.y <= bestHit.point.y) dynamicHit = null;
|
||||
if (dynamicHit) {
|
||||
bestHit = undefined;
|
||||
hitEntry = null;
|
||||
}
|
||||
// 射线阶段已经在网格与动态体之间选出最高命中,体积探测只作为失效时的补充。
|
||||
let groundPoint: THREE.Vector3 | undefined = dynamicHit?.point ?? bestHit?.point;
|
||||
let usedShapeGround = false;
|
||||
|
||||
// 中心射线没有近距离支撑时,用带宽度的体积探测覆盖细缝两侧。
|
||||
const rayGroundDistance = groundPoint
|
||||
? c.playerCapsule.position.y - groundPoint.y
|
||||
: Infinity;
|
||||
const groundCapsuleInfo = c.playerCapsule.capsuleInfo;
|
||||
|
||||
// 从胶囊下端球心开始向下探测。
|
||||
this.groundProbeStart.copy(groundCapsuleInfo.segment.end)
|
||||
.applyMatrix4(c.playerCapsule.matrixWorld);
|
||||
this.groundProbeDown.copy(c.upVector).negate();
|
||||
|
||||
const lowerCenterOffset = this.groundProbeOffset
|
||||
.subVectors(c.playerCapsule.position, this.groundProbeStart)
|
||||
.dot(c.upVector);
|
||||
const maxProbeDistance = Math.max(0, c.maxH - lowerCenterOffset);
|
||||
const sensorRadius = groundCapsuleInfo.radius * GROUND_SENSOR_RADIUS_RATIO;
|
||||
|
||||
// 近距离中心射线仍是快速路径;只有射线错过或命中过远时才执行体积查询。
|
||||
const useShapeProbe = rayGroundDistance > c.maxH;
|
||||
if (useShapeProbe) {
|
||||
let shapeDistance = Infinity;
|
||||
let shapeEntry: KinematicColliderEntry | null = null;
|
||||
let shapeDynamicHit: DynamicGroundHit | null = null;
|
||||
|
||||
// 静态、运动学及已登记的动态网格都通过 BVH 生成候选支撑面,保留最近命中。
|
||||
for (const col of c.collisionWorld.query({ mask: CHARACTER_QUERY_MASK })) {
|
||||
if (col.shape.kind !== "mesh") continue;
|
||||
const hit = probeMeshGround(
|
||||
this.groundProbeStart,
|
||||
this.groundProbeDown,
|
||||
maxProbeDistance,
|
||||
sensorRadius,
|
||||
GROUND_SUPPORT_NORMAL_Y,
|
||||
col.shape.mesh,
|
||||
this.groundProbeTemps,
|
||||
);
|
||||
if (!hit || hit.distance >= shapeDistance) continue;
|
||||
shapeDistance = hit.distance;
|
||||
this.shapeGroundPoint.copy(hit.point);
|
||||
this.shapeGroundNormal.copy(hit.normal);
|
||||
|
||||
// 保存运动学平台归属。
|
||||
shapeEntry = col.motion === "kinematic"
|
||||
? col.userData as KinematicColliderEntry
|
||||
: null;
|
||||
shapeDynamicHit = null;
|
||||
}
|
||||
|
||||
// 动态球和动态盒使用解析形状查询,再与网格结果竞争最近支撑面。
|
||||
const dynamicVolumeHit = c.dynamics.probeGroundVolume(
|
||||
this.groundProbeStart,
|
||||
this.groundProbeDown,
|
||||
maxProbeDistance,
|
||||
sensorRadius,
|
||||
GROUND_SUPPORT_NORMAL_Y,
|
||||
);
|
||||
if (dynamicVolumeHit) {
|
||||
const dynamicDistance = this.groundProbeOffset
|
||||
.subVectors(dynamicVolumeHit.point, this.groundProbeStart)
|
||||
.dot(this.groundProbeDown);
|
||||
if (dynamicDistance < shapeDistance) {
|
||||
shapeDistance = dynamicDistance;
|
||||
this.shapeGroundPoint.copy(dynamicVolumeHit.point);
|
||||
this.shapeGroundNormal.copy(dynamicVolumeHit.normal);
|
||||
shapeEntry = null;
|
||||
|
||||
// 保存动态刚体归属。
|
||||
shapeDynamicHit = dynamicVolumeHit;
|
||||
}
|
||||
}
|
||||
|
||||
if (Number.isFinite(shapeDistance)) {
|
||||
// 用体积探测结果替换失效射线,同时保持原有贴地、坡度和平台逻辑的输入格式。
|
||||
groundPoint = this.shapeGroundPoint;
|
||||
bestHit = undefined;
|
||||
dynamicHit = shapeDynamicHit;
|
||||
hitEntry = shapeEntry;
|
||||
usedShapeGround = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 调试圆柱挂在 capsule 下,传局部尺寸,由父级 scale 变换到世界尺度。
|
||||
const capsuleScale = c.playerCapsule.scale;
|
||||
const sy = capsuleScale.y || 1;
|
||||
const sxz = Math.max(1e-6, Math.min(capsuleScale.x || 1, capsuleScale.z || 1));
|
||||
this.debug.updateGroundSensor(
|
||||
this.groundProbeStart,
|
||||
this.groundProbeDown,
|
||||
maxProbeDistance / sy,
|
||||
sensorRadius / sxz,
|
||||
useShapeProbe,
|
||||
usedShapeGround,
|
||||
);
|
||||
this.debug.updateGroundProbe(
|
||||
c.groundRaycaster.ray.origin,
|
||||
groundPoint ?? null,
|
||||
c.maxH,
|
||||
c.groundRaycaster.near,
|
||||
);
|
||||
// —— 重力 / 贴地 snap(飞行跳过) ——
|
||||
if (!c.isFlying) {
|
||||
if (groundPoint) {
|
||||
const snapY = groundPoint.y + c.snapH;
|
||||
const dist = c.playerCapsule.position.y - groundPoint.y;
|
||||
if (dist > c.maxH) {
|
||||
c.applyGravity(delta); // 离地过远当坠落
|
||||
} else if (c.playerVelocity.y <= 0) {
|
||||
if (c.playerIsOnGround) {
|
||||
c.snapToGround(
|
||||
snapY,
|
||||
usedShapeGround
|
||||
? this.shapeGroundNormal.y >= c.minFloorNormalY
|
||||
: dynamicHit
|
||||
? dynamicHit.normal.y >= c.minFloorNormalY
|
||||
: c.isFlatFloor(bestHit!),
|
||||
delta,
|
||||
);
|
||||
} else {
|
||||
// 下落中:本帧会落到地面则贴地,否则继续重力
|
||||
const predictedY = c.playerCapsule.position.y + c.playerVelocity.y * delta;
|
||||
if (predictedY <= snapY) c.snapToGround(snapY);
|
||||
else c.applyGravity(delta);
|
||||
}
|
||||
} else {
|
||||
c.applyGravity(delta); // 上升(跳跃)
|
||||
}
|
||||
} else {
|
||||
c.applyGravity(delta);
|
||||
}
|
||||
c.playerCapsule.position.y += c.playerVelocity.y * delta;
|
||||
}
|
||||
|
||||
c.activeDynamicBody = c.playerIsOnGround ? dynamicHit?.body ?? null : null;
|
||||
c.activeKinematicCollider = c.playerIsOnGround && !c.activeDynamicBody ? hitEntry : null;
|
||||
this.setDynamicSupport(c.activeDynamicBody);
|
||||
|
||||
// —— 水平(飞行则含竖直)分步移动 + 胶囊推出 ——
|
||||
const capsuleInfo = c.playerCapsule.capsuleInfo;
|
||||
const xzSpeed = Math.hypot(c.playerVelocity.x, c.playerVelocity.z);
|
||||
const totalDist = c.isFlying ? c.playerVelocity.length() * delta : xzSpeed * delta;
|
||||
c.xzDir.set(c.playerVelocity.x, c.isFlying ? c.playerVelocity.y : 0, c.playerVelocity.z).normalize();
|
||||
const maxStep = capsuleInfo.radius * 0.8;
|
||||
const steps = Math.min(Math.max(1, Math.ceil(totalDist / maxStep)), c.maxMoveSteps);
|
||||
const stepDist = totalDist / steps;
|
||||
const blockedEpsSq = (maxStep * 0.05) ** 2;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
c.moveStepOrigin.copy(c.playerCapsule.position);
|
||||
c.playerCapsule.position.addScaledVector(c.xzDir, stepDist);
|
||||
c.playerCapsule.updateMatrixWorld();
|
||||
|
||||
if (!c.skipCapsuleCollision) {
|
||||
const kinMeshes = c.getKinematicColliderEntries();
|
||||
for (const mesh of c.getColliderMeshes()) {
|
||||
c.playerCapsule.updateMatrixWorld();
|
||||
const isKin = kinMeshes.some(e => e.mesh === mesh);
|
||||
// 飞行用接到脚底的段;步行用悬空段便于上台阶
|
||||
applyCapsuleCollision(
|
||||
c.playerCapsule,
|
||||
capsuleInfo,
|
||||
mesh,
|
||||
isKin ? c.kinematicTemps : c.staticTemps,
|
||||
undefined,
|
||||
c.isFlying,
|
||||
);
|
||||
}
|
||||
c.dynamics.collideWithCapsule();
|
||||
|
||||
// 本步几乎没走出去:卡住则提前结束剩余子步
|
||||
if (c.playerCapsule.position.distanceToSquared(c.moveStepOrigin) < blockedEpsSq) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 站在运动学平台上时跟随平台位移 / 偏航
|
||||
if (c.activeKinematicCollider && c.playerIsOnGround && !c.isFlying) {
|
||||
c.playerCapsule.position.add(c.activeKinematicCollider.deltaPos);
|
||||
if (c.activeKinematicCollider.deltaRotY !== 0) {
|
||||
c.playerCapsule.rotateY(c.activeKinematicCollider.deltaRotY);
|
||||
}
|
||||
}
|
||||
|
||||
// 在 IK 执行前缓存本帧最终接地结果。
|
||||
this.updateGroundSupport(groundPoint, bestHit, dynamicHit, usedShapeGround);
|
||||
|
||||
// 移动结束后刷新调试体,使地面射线/体积传感区与胶囊同步。
|
||||
c.playerCapsule.updateMatrixWorld(true);
|
||||
this.debug.refreshTransforms();
|
||||
|
||||
// —— 角色朝向(第三人称 / 飞行) ——
|
||||
if (!c.isFirstPerson) {
|
||||
const camDirFlat = c.camDir.clone().setY(0).normalize().negate();
|
||||
const moveDirFlat = c.moveDir.clone().normalize().negate();
|
||||
|
||||
if (!c.isFlying) {
|
||||
if (c.cam.mouseMode === 4 || c.cam.mouseMode === 5) {
|
||||
c.targetMat.lookAt(c.playerCapsule.position, c.playerCapsule.position.clone().add(camDirFlat), c.playerCapsule.up);
|
||||
c.playerCapsule.quaternion.copy(c.targetQuat.setFromRotationMatrix(c.targetMat));
|
||||
} else if (c.cam.mouseMode === 0 || c.cam.mouseMode === 2) {
|
||||
const lookTarget = c.playerCapsule.position.clone().add(moveDirFlat.lengthSq() > 0 ? moveDirFlat : camDirFlat);
|
||||
c.targetMat.lookAt(c.playerCapsule.position, lookTarget, c.playerCapsule.up);
|
||||
c.playerCapsule.quaternion.slerp(c.targetQuat.setFromRotationMatrix(c.targetMat), Math.min(1, c.rotationSpeed * delta));
|
||||
} else if (moveDirFlat.lengthSq() > 0) {
|
||||
c.targetMat.lookAt(c.playerCapsule.position, c.playerCapsule.position.clone().add(moveDirFlat), c.playerCapsule.up);
|
||||
c.playerCapsule.quaternion.slerp(c.targetQuat.setFromRotationMatrix(c.targetMat), Math.min(1, c.rotationSpeed * delta));
|
||||
}
|
||||
} else {
|
||||
const lookTarget = c.playerCapsule.position.clone().add(c.input.fwd ? moveDirFlat : camDirFlat);
|
||||
c.targetMat.lookAt(c.playerCapsule.position, lookTarget, c.playerCapsule.up);
|
||||
c.playerCapsule.quaternion.slerp(c.targetQuat.setFromRotationMatrix(c.targetMat), Math.min(1, c.rotationSpeed * delta));
|
||||
}
|
||||
}
|
||||
|
||||
// —— 第三人称相机跟随 ——
|
||||
if (!c.isFirstPerson) {
|
||||
const lookTarget = c.cam.springTarget(c.cam.getLookAtPoint(), delta);
|
||||
c.camera.position.sub(c.controls.target);
|
||||
c.camera.position.add(lookTarget);
|
||||
c.controls.target.copy(lookTarget);
|
||||
c.controls.update();
|
||||
c.cam.applyFlySprintMaxDist();
|
||||
c.cam.updateWithRaycast(c.controls.target);
|
||||
}
|
||||
|
||||
// 靠近车辆时显示上车按钮
|
||||
if (c.isShowMobileControls && c.vehicle.list.length) {
|
||||
let near = false;
|
||||
for (const veh of c.vehicle.list) {
|
||||
if (c.vehicle.isInBoardingRange(veh, c.playerCapsule.position)) {
|
||||
near = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (near !== c.isNearVehicle) {
|
||||
c.isNearVehicle = near;
|
||||
c.mobileControls?.syncVehicleBtn(near);
|
||||
}
|
||||
}
|
||||
|
||||
c.animation.setAnimationByPressed();
|
||||
c.runAnimationPass(delta);
|
||||
}
|
||||
|
||||
/** 将射线或体积探测最终采用的支撑点统一转换成世界空间结果。 */
|
||||
private updateGroundSupport(
|
||||
point: THREE.Vector3 | undefined,
|
||||
meshHit: THREE.Intersection | undefined,
|
||||
dynamicHit: DynamicGroundHit | null,
|
||||
usedShapeGround: boolean,
|
||||
): void {
|
||||
const c = this.ctrl;
|
||||
if (c.isFlying || !c.playerIsOnGround || !point) {
|
||||
c.clearGroundSupport();
|
||||
return;
|
||||
}
|
||||
|
||||
if (usedShapeGround) {
|
||||
// 体积探测已经返回世界空间法线。
|
||||
this.rayGroundNormal.copy(this.shapeGroundNormal);
|
||||
} else if (dynamicHit) {
|
||||
this.rayGroundNormal.copy(dynamicHit.normal);
|
||||
} else if (meshHit?.face) {
|
||||
// 射线命中网格法线转换到世界空间。
|
||||
this.rayGroundNormal.copy(meshHit.face.normal)
|
||||
.applyMatrix3(this.rayGroundNormalMatrix.getNormalMatrix(meshHit.object.matrixWorld))
|
||||
.normalize();
|
||||
} else {
|
||||
this.rayGroundNormal.copy(c.upVector);
|
||||
}
|
||||
|
||||
c.setGroundSupport(point, this.rayGroundNormal);
|
||||
}
|
||||
|
||||
/** 切换当前动态支撑体并记录位姿。 */
|
||||
private setDynamicSupport(body: DynamicBody | null): void {
|
||||
if (this.dynamicSupportBody === body) return;
|
||||
this.dynamicSupportBody = body;
|
||||
if (!body) return;
|
||||
this.dynamicSupportPrevPosition.copy(body.position);
|
||||
this.dynamicSupportPrevQuaternion.copy(body.quaternion);
|
||||
}
|
||||
|
||||
/** 动态支撑体推进后按前后位姿带走人物。 */
|
||||
applyDynamicSupportCarry(): void {
|
||||
const c = this.ctrl;
|
||||
const body = c.activeDynamicBody;
|
||||
if (body !== this.dynamicSupportBody) this.setDynamicSupport(body);
|
||||
if (!body) return;
|
||||
if (!c.dynamics.list.includes(body)) {
|
||||
c.activeDynamicBody = null;
|
||||
this.setDynamicSupport(null);
|
||||
return;
|
||||
}
|
||||
if (c.controllerMode === 1 || c.isFlying || !c.playerIsOnGround) {
|
||||
this.dynamicSupportPrevPosition.copy(body.position);
|
||||
this.dynamicSupportPrevQuaternion.copy(body.quaternion);
|
||||
return;
|
||||
}
|
||||
|
||||
this.dynamicSupportBefore.copy(c.playerCapsule.position);
|
||||
this.dynamicSupportInvQuaternion.copy(this.dynamicSupportPrevQuaternion).invert();
|
||||
this.dynamicSupportLocal.subVectors(
|
||||
c.playerCapsule.position,
|
||||
this.dynamicSupportPrevPosition,
|
||||
).applyQuaternion(this.dynamicSupportInvQuaternion);
|
||||
c.playerCapsule.position.copy(this.dynamicSupportLocal)
|
||||
.applyQuaternion(body.quaternion)
|
||||
.add(body.position);
|
||||
c.playerCapsule.updateMatrixWorld();
|
||||
|
||||
this.dynamicSupportDelta.subVectors(c.playerCapsule.position, this.dynamicSupportBefore);
|
||||
if (!c.isFirstPerson && this.dynamicSupportDelta.lengthSq() > 0) {
|
||||
c.camera.position.add(this.dynamicSupportDelta);
|
||||
c.controls.target.add(this.dynamicSupportDelta);
|
||||
}
|
||||
this.dynamicSupportPrevPosition.copy(body.position);
|
||||
this.dynamicSupportPrevQuaternion.copy(body.quaternion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import * as THREE from "three";
|
||||
import type { playerController } from "../PlayerController";
|
||||
import type { ColliderDesc, ColliderHandle, DynamicColliderDesc } from "../collision/colliderDesc";
|
||||
import type { MotionType } from "../collision/CollisionWorld";
|
||||
import {
|
||||
attachBoundsTree,
|
||||
buildKinematicMergedMesh,
|
||||
buildStaticMergedMesh,
|
||||
} from "../collision/colliderBuild";
|
||||
import type { KinematicColliderEntry } from "../types";
|
||||
|
||||
let warnedDynamicMesh = false;
|
||||
|
||||
/**
|
||||
* 统一碰撞体注册:按 ColliderDesc 创建 / 移除,并维护运动学跟随。
|
||||
* 静态 / 运动学 mesh 走合并 + BVH;动态球 / 盒交给 DynamicBodySystem。
|
||||
*/
|
||||
export class ColliderRegistry {
|
||||
private ctrl: playerController;
|
||||
kinematicColliders: KinematicColliderEntry[] = []; // 需每帧跟随的运动学项
|
||||
/** id → handle 元数据 */
|
||||
private handles = new Map<number, ColliderHandle>();
|
||||
/** 静态构建代数:异步 BVH 完成时若代数变了则丢弃 */
|
||||
private buildGens = new Map<number, number>();
|
||||
private _contentScale = new THREE.Vector3();
|
||||
private _contentOffsetMatrix = new THREE.Matrix4();
|
||||
/** 静态 / 运动学 mesh 碰撞线框开关。 */
|
||||
private debugVisible = false;
|
||||
|
||||
constructor(ctrl: playerController) {
|
||||
this.ctrl = ctrl;
|
||||
}
|
||||
|
||||
/** 返回碰撞 mesh 调试开关。 */
|
||||
getDebugVisible(): boolean {
|
||||
return this.debugVisible;
|
||||
}
|
||||
|
||||
/** 切换静态 / 运动学 mesh 碰撞线框。 */
|
||||
setDebugVisible(visible: boolean): void {
|
||||
this.debugVisible = visible;
|
||||
this.syncDebugVisibility();
|
||||
}
|
||||
|
||||
/** 同步已就绪碰撞 mesh 的调试显隐。 */
|
||||
syncDebugVisibility(): void {
|
||||
const synced = new Set<THREE.Mesh>();
|
||||
for (const handle of this.handles.values()) {
|
||||
const mesh = handle.collisionMesh;
|
||||
if (!mesh || synced.has(mesh)) continue;
|
||||
synced.add(mesh);
|
||||
this.syncDebugMesh(mesh);
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 desc 创建碰撞体;校验 motion/shape 约束后分派到 mesh/box/sphere。 */
|
||||
add(desc: ColliderDesc): ColliderHandle {
|
||||
if (desc.motion === "static" && (desc as { follow?: unknown }).follow != null) {
|
||||
throw new Error("static collider 不能带 follow");
|
||||
}
|
||||
if (desc.motion === "dynamic" && (desc as { follow?: unknown }).follow != null) {
|
||||
throw new Error("dynamic collider 不能带 follow");
|
||||
}
|
||||
if (desc.shape.kind === "mesh") {
|
||||
const hasSource = "source" in desc.shape && desc.shape.source != null;
|
||||
const hasMesh = "mesh" in desc.shape && desc.shape.mesh != null;
|
||||
if (hasSource === hasMesh) {
|
||||
throw new Error('mesh shape 必须提供 source 或 mesh 之一');
|
||||
}
|
||||
if (desc.motion === "dynamic" && !warnedDynamicMesh) {
|
||||
warnedDynamicMesh = true;
|
||||
console.warn(
|
||||
"[three-player-controller] dynamic + mesh 允许但不推荐:三角网格无体积,易卡顿/穿透;动态物请优先 box/sphere。",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
switch (desc.shape.kind) {
|
||||
case "mesh":
|
||||
return this.addMesh(desc);
|
||||
case "box":
|
||||
return this.addBox(desc);
|
||||
case "sphere":
|
||||
return this.addSphere(desc);
|
||||
default:
|
||||
throw new Error("未知 shape.kind");
|
||||
}
|
||||
}
|
||||
|
||||
/** 按句柄或 id 移除;同步释放 mesh / 动态刚体与 CollisionWorld 登记。 */
|
||||
remove(handle: ColliderHandle | number): void {
|
||||
const id = typeof handle === "number" ? handle : handle.id;
|
||||
if (id < 0) return;
|
||||
const meta = this.handles.get(id);
|
||||
const kin = this.kinematicColliders.find(e => e.worldId === id);
|
||||
if (kin) {
|
||||
kin.buildId += 1; // 作废进行中的异步 BVH
|
||||
this.ctrl.scene.remove(kin.mesh);
|
||||
kin.mesh.geometry.dispose();
|
||||
(kin.mesh.material as THREE.Material).dispose();
|
||||
if (this.ctrl.activeKinematicCollider === kin) this.ctrl.activeKinematicCollider = null;
|
||||
this.kinematicColliders = this.kinematicColliders.filter(e => e !== kin);
|
||||
} else if (meta?.collisionMesh) {
|
||||
this.ctrl.scene.remove(meta.collisionMesh);
|
||||
meta.collisionMesh.geometry.dispose();
|
||||
(meta.collisionMesh.material as THREE.Material).dispose();
|
||||
}
|
||||
|
||||
// 动态刚体:remove 内部会卸 CollisionWorld
|
||||
const body = this.ctrl.dynamics.findByColliderId(id);
|
||||
if (body) {
|
||||
this.ctrl.dynamics.remove(body);
|
||||
this.handles.delete(id);
|
||||
this.buildGens.delete(id);
|
||||
return;
|
||||
}
|
||||
|
||||
this.ctrl.collisionWorld.remove(id);
|
||||
this.handles.delete(id);
|
||||
this.buildGens.delete(id);
|
||||
}
|
||||
|
||||
/** 清除碰撞体;可按 motion 过滤。 */
|
||||
clear(filter?: { motion?: MotionType }): void {
|
||||
for (const id of this.ctrl.collisionWorld.idsByMotion(filter?.motion)) {
|
||||
this.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 follow/source Object3D 查找运动学项。 */
|
||||
findKinematicBySource(source: THREE.Object3D): KinematicColliderEntry | undefined {
|
||||
return this.kinematicColliders.find(e => e.source === source);
|
||||
}
|
||||
|
||||
/**
|
||||
* 每帧同步运动学碰撞网格到 follow 的 matrixWorld。
|
||||
* 若人物站在平台上,用 prev→current 算出本帧应带走的 deltaPos / deltaRotY。
|
||||
*/
|
||||
updateKinematicFollow(): void {
|
||||
const playerWorldPos = this.ctrl.playerCapsule?.position;
|
||||
for (const entry of this.kinematicColliders) {
|
||||
if (playerWorldPos) {
|
||||
const prevInv = new THREE.Matrix4().copy(entry.prevWorldMatrix).invert();
|
||||
const playerInLocal = playerWorldPos.clone().applyMatrix4(prevInv);
|
||||
this.writeKinematicMeshMatrix(entry);
|
||||
const playerInNewWorld = playerInLocal.clone().applyMatrix4(entry.mesh.matrixWorld);
|
||||
entry.deltaPos.subVectors(playerInNewWorld, playerWorldPos);
|
||||
} else {
|
||||
this.writeKinematicMeshMatrix(entry);
|
||||
entry.deltaPos.set(0, 0, 0);
|
||||
}
|
||||
const prevEuler = new THREE.Euler().setFromRotationMatrix(entry.prevWorldMatrix, "YXZ");
|
||||
const curEuler = new THREE.Euler().setFromRotationMatrix(entry.mesh.matrixWorld, "YXZ");
|
||||
entry.deltaRotY = curEuler.y - prevEuler.y;
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 follow 世界矩阵(含 contentOffset / bakeInverse / contentScale)写入碰撞 mesh。 */
|
||||
private writeKinematicMeshMatrix(entry: KinematicColliderEntry): void {
|
||||
entry.source.updateMatrixWorld(true);
|
||||
entry.mesh.matrix.copy(entry.source.matrixWorld);
|
||||
if (entry.contentOffset.lengthSq() > 0) {
|
||||
entry.mesh.matrix.multiply(
|
||||
this._contentOffsetMatrix.makeTranslation(
|
||||
entry.contentOffset.x,
|
||||
entry.contentOffset.y,
|
||||
entry.contentOffset.z,
|
||||
),
|
||||
);
|
||||
}
|
||||
entry.mesh.matrix.multiply(entry.bakeInverse);
|
||||
const s = entry.contentScale;
|
||||
if (s !== 1) {
|
||||
entry.mesh.matrix.scale(this._contentScale.set(s, s, s));
|
||||
}
|
||||
entry.mesh.updateMatrixWorld(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时等比缩放已烘焙的运动学 mesh 碰撞(不重建 BVH)。
|
||||
* source 为注册时的 follow / vehicleGroup。
|
||||
*/
|
||||
scaleKinematicContent(source: THREE.Object3D, ratio: number): void {
|
||||
if (!source || !Number.isFinite(ratio) || ratio === 1) return;
|
||||
const entry = this.kinematicColliders.find(e => e.source === source);
|
||||
if (!entry) return;
|
||||
entry.contentScale *= ratio;
|
||||
entry.contentOffset.multiplyScalar(ratio);
|
||||
this.writeKinematicMeshMatrix(entry);
|
||||
entry.prevWorldMatrix.copy(entry.mesh.matrixWorld);
|
||||
}
|
||||
|
||||
/** 运行时平移已烘焙的运动学 mesh 碰撞,不重建 BVH。 */
|
||||
translateKinematicContent(source: THREE.Object3D, localOffset: THREE.Vector3): void {
|
||||
if (!source || !Number.isFinite(localOffset.lengthSq()) || localOffset.lengthSq() === 0) return;
|
||||
const entry = this.kinematicColliders.find(e => e.source === source);
|
||||
if (!entry) return;
|
||||
entry.contentOffset.add(localOffset);
|
||||
this.writeKinematicMeshMatrix(entry);
|
||||
entry.prevWorldMatrix.copy(entry.mesh.matrixWorld);
|
||||
}
|
||||
|
||||
/** 本帧物理用完后再提交平台矩阵,供下一帧算 delta。 */
|
||||
commitKinematicPrev(): void {
|
||||
for (const entry of this.kinematicColliders) {
|
||||
entry.prevWorldMatrix.copy(entry.mesh.matrixWorld);
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否已有至少一份带 BVH 的静态 mesh。 */
|
||||
isStaticUsable(): boolean {
|
||||
for (const col of this.ctrl.collisionWorld.query(
|
||||
{ mask: 0xffff },
|
||||
{ motion: "static", kinds: ["mesh"] },
|
||||
)) {
|
||||
if (col.shape.kind === "mesh" && (col.shape.mesh.geometry as any).boundsTree) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 运动学项是否已就绪(BVH 可用)。 */
|
||||
isKinematicUsable(entry: KinematicColliderEntry): boolean {
|
||||
return entry.ready && Boolean((entry.mesh.geometry as any).boundsTree);
|
||||
}
|
||||
|
||||
/** 写入 handles 并返回公开句柄。 */
|
||||
private makeHandle(
|
||||
id: number,
|
||||
motion: MotionType,
|
||||
follow: THREE.Object3D | null,
|
||||
source: THREE.Object3D | null,
|
||||
collisionMesh: THREE.Mesh | null,
|
||||
): ColliderHandle {
|
||||
const h: ColliderHandle = { id, motion, follow, source, collisionMesh };
|
||||
this.handles.set(id, h);
|
||||
return h;
|
||||
}
|
||||
|
||||
/** mesh:按 motion 走动态登记 / 运动学 / 静态合并。 */
|
||||
private addMesh(desc: ColliderDesc): ColliderHandle {
|
||||
const shape = desc.shape as Extract<ColliderDesc["shape"], { kind: "mesh" }>;
|
||||
if (desc.motion === "dynamic") {
|
||||
// 允许但不推荐:登记已有 mesh,不合并、不建刚体求解
|
||||
const mesh = "mesh" in shape && shape.mesh
|
||||
? shape.mesh
|
||||
: (() => {
|
||||
throw new Error("dynamic + mesh 请传入已有 mesh,或改用 box/sphere");
|
||||
})();
|
||||
const entry = this.ctrl.collisionWorld.add({
|
||||
motion: "dynamic",
|
||||
shape: { kind: "mesh", mesh },
|
||||
groups: desc.groups,
|
||||
mask: desc.mask,
|
||||
userData: desc.userData,
|
||||
});
|
||||
return this.makeHandle(entry.id, "dynamic", null, null, mesh);
|
||||
}
|
||||
|
||||
if (desc.motion === "kinematic") {
|
||||
return this.addKinematicMesh(desc);
|
||||
}
|
||||
|
||||
// static:已有 BVH mesh 直接登记,否则合并 source 再挂 BVH
|
||||
const source = "source" in shape && shape.source
|
||||
? shape.source
|
||||
: null;
|
||||
const readyMesh = "mesh" in shape && shape.mesh ? shape.mesh : null;
|
||||
|
||||
if (readyMesh) {
|
||||
const entry = this.ctrl.collisionWorld.add({
|
||||
motion: "static",
|
||||
shape: { kind: "mesh", mesh: readyMesh },
|
||||
groups: desc.groups,
|
||||
mask: desc.mask,
|
||||
ready: Boolean((readyMesh.geometry as any).boundsTree),
|
||||
userData: desc.userData,
|
||||
});
|
||||
this.syncDebugMesh(readyMesh);
|
||||
return this.makeHandle(entry.id, "static", null, null, readyMesh);
|
||||
}
|
||||
|
||||
const built = buildStaticMergedMesh(source!);
|
||||
if (!built) {
|
||||
console.warn("静态 mesh 合并失败:无可用几何");
|
||||
return { id: -1, motion: "static", follow: null, source: null, collisionMesh: null };
|
||||
}
|
||||
|
||||
const world = this.ctrl.collisionWorld.add({
|
||||
motion: "static",
|
||||
shape: { kind: "mesh", mesh: built.mesh },
|
||||
groups: desc.groups,
|
||||
mask: desc.mask,
|
||||
ready: false, // BVH 完成前不参与查询
|
||||
userData: desc.userData,
|
||||
});
|
||||
const handle = this.makeHandle(
|
||||
world.id,
|
||||
"static",
|
||||
null,
|
||||
Array.isArray(source) ? (source[0] ?? null) : source,
|
||||
built.mesh,
|
||||
);
|
||||
this.buildGens.set(world.id, 1);
|
||||
const gen = 1;
|
||||
|
||||
attachBoundsTree(built.geometry, {
|
||||
useWorker: desc.useWorker,
|
||||
workerPool: this.ctrl.getBvhWorkerPool(),
|
||||
maxDepth: 100,
|
||||
onStale: () => this.buildGens.get(world.id) !== gen || this.ctrl.collisionWorld.get(world.id) == null,
|
||||
onReady: () => {
|
||||
if (this.buildGens.get(world.id) !== gen) return;
|
||||
this.ctrl.collisionWorld.setReady(world.id, true);
|
||||
this.syncDebugMesh(built.mesh);
|
||||
},
|
||||
});
|
||||
return handle;
|
||||
}
|
||||
|
||||
/** 运动学 mesh:已有 mesh 或从 source 合并,并加入 kinematicColliders 跟随列表。 */
|
||||
private addKinematicMesh(desc: ColliderDesc): ColliderHandle {
|
||||
const shape = desc.shape as Extract<ColliderDesc["shape"], { kind: "mesh" }>;
|
||||
const follow =
|
||||
("follow" in desc ? desc.follow : undefined)
|
||||
?? (("source" in shape && !Array.isArray(shape.source)) ? shape.source : undefined)
|
||||
?? null;
|
||||
|
||||
if ("mesh" in shape && shape.mesh) {
|
||||
const mesh = shape.mesh;
|
||||
mesh.matrixAutoUpdate = false;
|
||||
if (follow) {
|
||||
follow.updateMatrixWorld(true);
|
||||
mesh.matrix.copy(follow.matrixWorld);
|
||||
mesh.updateMatrixWorld(true);
|
||||
}
|
||||
const entry: KinematicColliderEntry = {
|
||||
source: follow ?? mesh,
|
||||
mesh,
|
||||
contentScale: 1,
|
||||
contentOffset: new THREE.Vector3(),
|
||||
bakeInverse: new THREE.Matrix4(),
|
||||
prevWorldMatrix: new THREE.Matrix4().copy(follow?.matrixWorld ?? mesh.matrixWorld),
|
||||
deltaPos: new THREE.Vector3(),
|
||||
deltaRotY: 0,
|
||||
ready: Boolean((mesh.geometry as any).boundsTree),
|
||||
buildId: 1,
|
||||
worldId: 0,
|
||||
};
|
||||
const world = this.ctrl.collisionWorld.add({
|
||||
motion: "kinematic",
|
||||
shape: { kind: "mesh", mesh },
|
||||
groups: desc.groups,
|
||||
mask: desc.mask,
|
||||
ready: entry.ready,
|
||||
userData: entry,
|
||||
});
|
||||
entry.worldId = world.id;
|
||||
this.kinematicColliders.push(entry);
|
||||
this.syncDebugMesh(mesh);
|
||||
return this.makeHandle(world.id, "kinematic", follow, follow, mesh);
|
||||
}
|
||||
|
||||
const sourceObj = shape.source;
|
||||
const root = Array.isArray(sourceObj) ? sourceObj[0] : sourceObj;
|
||||
if (!root) throw new Error("kinematic mesh 需要 source");
|
||||
|
||||
// 同一 follow/source 已注册则复用,避免重复合并
|
||||
const existing = this.kinematicColliders.find(e => e.source === (follow ?? root));
|
||||
if (existing) {
|
||||
return this.handles.get(existing.worldId)
|
||||
?? this.makeHandle(existing.worldId, "kinematic", follow ?? root, root, existing.mesh);
|
||||
}
|
||||
|
||||
const built = buildKinematicMergedMesh(root);
|
||||
if (!built) {
|
||||
console.warn("运动学 mesh 合并失败:无可用几何");
|
||||
return { id: -1, motion: "kinematic", follow: follow ?? root, source: root, collisionMesh: null };
|
||||
}
|
||||
|
||||
const followTarget = follow ?? root;
|
||||
followTarget.updateMatrixWorld(true);
|
||||
// verts 已在世界空间收心
|
||||
const bakeInverse = new THREE.Matrix4()
|
||||
.copy(followTarget.matrixWorld)
|
||||
.invert()
|
||||
.multiply(built.mesh.matrix);
|
||||
const entry: KinematicColliderEntry = {
|
||||
source: followTarget,
|
||||
mesh: built.mesh,
|
||||
contentScale: 1,
|
||||
contentOffset: new THREE.Vector3(),
|
||||
bakeInverse,
|
||||
prevWorldMatrix: new THREE.Matrix4().copy(built.mesh.matrixWorld),
|
||||
deltaPos: new THREE.Vector3(),
|
||||
deltaRotY: 0,
|
||||
ready: !desc.useWorker, // Worker 时先不可用,onReady 再打开
|
||||
buildId: 1,
|
||||
worldId: 0,
|
||||
};
|
||||
const world = this.ctrl.collisionWorld.add({
|
||||
motion: "kinematic",
|
||||
shape: { kind: "mesh", mesh: built.mesh },
|
||||
groups: desc.groups,
|
||||
mask: desc.mask,
|
||||
ready: entry.ready,
|
||||
userData: entry,
|
||||
});
|
||||
entry.worldId = world.id;
|
||||
this.kinematicColliders.push(entry);
|
||||
const handle = this.makeHandle(world.id, "kinematic", followTarget, root, built.mesh);
|
||||
const buildId = entry.buildId;
|
||||
|
||||
attachBoundsTree(built.geometry, {
|
||||
useWorker: desc.useWorker,
|
||||
workerPool: this.ctrl.getBvhWorkerPool(),
|
||||
onStale: () => entry.buildId !== buildId || !this.kinematicColliders.includes(entry),
|
||||
onReady: () => {
|
||||
if (entry.buildId !== buildId || !this.kinematicColliders.includes(entry)) return;
|
||||
entry.ready = true;
|
||||
this.ctrl.collisionWorld.setReady(entry.worldId, true);
|
||||
this.syncDebugMesh(built.mesh);
|
||||
},
|
||||
});
|
||||
return handle;
|
||||
}
|
||||
|
||||
/** 按当前开关同步单个碰撞 mesh。 */
|
||||
private syncDebugMesh(mesh: THREE.Mesh): void {
|
||||
if (this.debugVisible && (mesh.geometry as any).boundsTree) {
|
||||
if (!this.ctrl.scene.children.includes(mesh)) this.ctrl.scene.add(mesh);
|
||||
} else {
|
||||
this.ctrl.scene.remove(mesh);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* box:
|
||||
* - dynamic 且 simulate 默认 true → 创建 DynamicBoxBody
|
||||
* - dynamic 且 simulate: false → 仅登记(车辆底盘)
|
||||
* - static / kinematic → 仅登记
|
||||
*/
|
||||
private addBox(desc: ColliderDesc): ColliderHandle {
|
||||
if (desc.motion === "dynamic" && (desc as DynamicColliderDesc).simulate !== false) {
|
||||
const body = this.ctrl.dynamics.addBox(desc as DynamicColliderDesc);
|
||||
if (desc.groups != null) this.ctrl.collisionWorld.setGroups(body.colliderId, desc.groups);
|
||||
if (desc.mask != null) this.ctrl.collisionWorld.setMask(body.colliderId, desc.mask);
|
||||
if (desc.userData != null) this.ctrl.collisionWorld.setUserData(body.colliderId, desc.userData);
|
||||
return this.makeHandle(body.colliderId, "dynamic", null, null, null);
|
||||
}
|
||||
|
||||
const shape = desc.shape as Extract<ColliderDesc["shape"], { kind: "box" }>;
|
||||
const half = shape.halfExtents.clone();
|
||||
const entry = this.ctrl.collisionWorld.add({
|
||||
motion: desc.motion,
|
||||
shape: { kind: "box", halfExtents: half },
|
||||
groups: desc.groups,
|
||||
mask: desc.mask,
|
||||
userData: desc.userData,
|
||||
});
|
||||
return this.makeHandle(entry.id, desc.motion, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* sphere:dynamic 时创建 DynamicSphereBody;
|
||||
* static/kinematic 仅登记形状(供查询矩阵,无球刚体步进)。
|
||||
*/
|
||||
private addSphere(desc: ColliderDesc): ColliderHandle {
|
||||
if (desc.motion === "dynamic") {
|
||||
const body = this.ctrl.dynamics.addSphere(desc as DynamicColliderDesc);
|
||||
if (desc.groups != null) this.ctrl.collisionWorld.setGroups(body.colliderId, desc.groups);
|
||||
if (desc.mask != null) this.ctrl.collisionWorld.setMask(body.colliderId, desc.mask);
|
||||
if (desc.userData != null) this.ctrl.collisionWorld.setUserData(body.colliderId, desc.userData);
|
||||
return this.makeHandle(body.colliderId, "dynamic", null, null, null);
|
||||
}
|
||||
|
||||
const shape = desc.shape as Extract<ColliderDesc["shape"], { kind: "sphere" }>;
|
||||
const entry = this.ctrl.collisionWorld.add({
|
||||
motion: desc.motion,
|
||||
shape: { kind: "sphere", radius: shape.radius },
|
||||
groups: desc.groups,
|
||||
mask: desc.mask,
|
||||
userData: desc.userData,
|
||||
});
|
||||
return this.makeHandle(entry.id, desc.motion, null, null, null);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,247 @@
|
||||
import type { playerController } from "../PlayerController";
|
||||
import type { KeyAction, KeyMap } from "../types";
|
||||
|
||||
// 默认键位表(动作 -> KeyboardEvent.code 列表)
|
||||
const defaultKeyMap: Record<KeyAction, string[]> = {
|
||||
forward: ["KeyW", "ArrowUp"],
|
||||
backward: ["KeyS", "ArrowDown"],
|
||||
left: ["KeyA", "ArrowLeft"],
|
||||
right: ["KeyD", "ArrowRight"],
|
||||
sprint: ["ShiftLeft", "ShiftRight"],
|
||||
jump: ["Space"],
|
||||
toggleView: ["KeyV"],
|
||||
toggleFly: ["KeyF"],
|
||||
toggleVehicle: ["KeyE"],
|
||||
};
|
||||
|
||||
export class InputSystem {
|
||||
private ctrl: playerController; // 主控制器引用
|
||||
|
||||
fwd = false; // 前进键
|
||||
bkd = false; // 后退键
|
||||
lft = false; // 左移键
|
||||
rgt = false; // 右移键
|
||||
space = false; // 跳跃键
|
||||
shift = false; // 加速键
|
||||
|
||||
private keyFwd = false;
|
||||
private keyBkd = false;
|
||||
private keyLft = false;
|
||||
private keyRgt = false;
|
||||
private analogMoveX = 0;
|
||||
private analogMoveY = 0;
|
||||
|
||||
private boundKeydown = async (e: KeyboardEvent) => this.onKeydown(e); // 键盘按下绑定
|
||||
private boundKeyup = (e: KeyboardEvent) => this.onKeyup(e); // 键盘抬起绑定
|
||||
private boundMouseMove = (e: MouseEvent) => this.onMouseMove(e); // 鼠标移动绑定
|
||||
private boundMouseClick = (e: MouseEvent) => {
|
||||
if (e.target === this.ctrl.controls.domElement) this.ctrl.cam.setPointerLock(); // 鼠标点击绑定
|
||||
};
|
||||
private boundBlur = () => this.resetKeys(); // 页面失焦时重置按键状态
|
||||
|
||||
private codeToAction = new Map<string, KeyAction>(); // 键码 -> 动作 反查表
|
||||
|
||||
constructor(ctrl: playerController) {
|
||||
this.ctrl = ctrl;
|
||||
this.buildKeyMap();
|
||||
}
|
||||
|
||||
// 构建键码:动作 反查表:未传的动作用默认键,传 string/数组则覆盖,传 null 则禁用
|
||||
buildKeyMap(userMap?: KeyMap) {
|
||||
this.codeToAction.clear(); // 清空旧表
|
||||
for (const action of Object.keys(defaultKeyMap) as KeyAction[]) {
|
||||
let codes: string[];
|
||||
if (userMap && action in userMap) {
|
||||
const v = userMap[action];
|
||||
if (v == null) continue; // null:禁用该动作
|
||||
codes = Array.isArray(v) ? v : [v]; // 覆盖默认键
|
||||
} else {
|
||||
codes = defaultKeyMap[action]; // 未传:用默认键
|
||||
}
|
||||
for (const code of codes) this.codeToAction.set(code, action);
|
||||
}
|
||||
}
|
||||
|
||||
// 程序化输入接口
|
||||
setInput(input: Partial<{
|
||||
moveX: number; moveY: number;
|
||||
lookDeltaX: number; lookDeltaY: number;
|
||||
jump: boolean; shift: boolean;
|
||||
toggleView: boolean; toggleFly: boolean; toggleVehicle: boolean;
|
||||
}>) {
|
||||
const c = this.ctrl;
|
||||
|
||||
// 连续移动轴,供摇杆或手柄输入使用
|
||||
const prevFwd = this.fwd;
|
||||
const prevBkd = this.bkd;
|
||||
const prevLft = this.lft;
|
||||
const prevRgt = this.rgt;
|
||||
let moveChanged = false;
|
||||
if (typeof input.moveX === "number") {
|
||||
this.analogMoveX = Math.max(-1, Math.min(1, input.moveX));
|
||||
moveChanged = true;
|
||||
}
|
||||
if (typeof input.moveY === "number") {
|
||||
this.analogMoveY = Math.max(-1, Math.min(1, input.moveY));
|
||||
moveChanged = true;
|
||||
}
|
||||
if (moveChanged) {
|
||||
this.syncDirectionFlags();
|
||||
if (prevFwd !== this.fwd || prevBkd !== this.bkd || prevLft !== this.lft || prevRgt !== this.rgt) {
|
||||
c.animation.setAnimationByPressed();
|
||||
}
|
||||
}
|
||||
|
||||
// 视角朝向
|
||||
if (typeof input.lookDeltaX === "number" && typeof input.lookDeltaY === "number") {
|
||||
c.cam.setToward(input.lookDeltaX, input.lookDeltaY, 0.002);
|
||||
}
|
||||
|
||||
// 持续状态
|
||||
if (typeof input.jump === "boolean") this.applyAction("jump", input.jump);
|
||||
if (typeof input.shift === "boolean") this.applyAction("sprint", input.shift);
|
||||
|
||||
// 触发式切换
|
||||
if (input.toggleView) this.applyAction("toggleView", true);
|
||||
if (input.toggleFly) this.applyAction("toggleFly", true);
|
||||
if (input.toggleVehicle) this.applyAction("toggleVehicle", true);
|
||||
}
|
||||
|
||||
// 绑定输入事件
|
||||
bindEvents() {
|
||||
this.ctrl.isupdate = true;
|
||||
this.ctrl.cam.setPointerLock();
|
||||
window.addEventListener("keydown", this.boundKeydown);
|
||||
window.addEventListener("keyup", this.boundKeyup);
|
||||
window.addEventListener("mousemove", this.boundMouseMove);
|
||||
window.addEventListener("click", this.boundMouseClick);
|
||||
window.addEventListener("blur", this.boundBlur);
|
||||
}
|
||||
|
||||
// 解绑输入事件
|
||||
unbindEvents() {
|
||||
this.ctrl.isupdate = false;
|
||||
document.exitPointerLock();
|
||||
window.removeEventListener("keydown", this.boundKeydown);
|
||||
window.removeEventListener("keyup", this.boundKeyup);
|
||||
window.removeEventListener("mousemove", this.boundMouseMove);
|
||||
window.removeEventListener("click", this.boundMouseClick);
|
||||
window.removeEventListener("blur", this.boundBlur);
|
||||
}
|
||||
|
||||
// 重置所有按键状态
|
||||
private resetKeys() {
|
||||
const c = this.ctrl;
|
||||
this.keyFwd = false;
|
||||
this.keyBkd = false;
|
||||
this.keyLft = false;
|
||||
this.keyRgt = false;
|
||||
this.analogMoveX = 0;
|
||||
this.analogMoveY = 0;
|
||||
this.syncDirectionFlags();
|
||||
this.space = false;
|
||||
this.shift = false;
|
||||
c.controls.mouseButtons = { LEFT: 0, MIDDLE: 1, RIGHT: 2 };
|
||||
c.animation.setAnimationByPressed();
|
||||
}
|
||||
|
||||
// 统一动作派发
|
||||
private applyAction(action: KeyAction, pressed: boolean) {
|
||||
const c = this.ctrl;
|
||||
switch (action) {
|
||||
// 前进
|
||||
case "forward": this.keyFwd = pressed; this.syncDirectionFlags(); c.animation.setAnimationByPressed(); break;
|
||||
// 后退
|
||||
case "backward": this.keyBkd = pressed; this.syncDirectionFlags(); c.animation.setAnimationByPressed(); break;
|
||||
// 左移
|
||||
case "left": this.keyLft = pressed; this.syncDirectionFlags(); c.animation.setAnimationByPressed(); break;
|
||||
// 右移
|
||||
case "right": this.keyRgt = pressed; this.syncDirectionFlags(); c.animation.setAnimationByPressed(); break;
|
||||
// 冲刺
|
||||
case "sprint":
|
||||
this.shift = pressed;
|
||||
c.animation.setAnimationByPressed();
|
||||
// 切换轨道拖拽键位
|
||||
c.controls.mouseButtons = pressed
|
||||
? { LEFT: 2, MIDDLE: 1, RIGHT: 0 }
|
||||
: { LEFT: 0, MIDDLE: 1, RIGHT: 2 };
|
||||
break;
|
||||
// 跳跃
|
||||
case "jump":
|
||||
if (pressed) {
|
||||
this.space = true;
|
||||
if (c.controllerMode === 1) return; // 载具模式不跳跃
|
||||
if (c.isFlying) { c.animation.setAnimationByPressed(); return; } // 飞行中仅切动画
|
||||
if (!c.playerIsOnGround) return; // 不在地面不能跳
|
||||
if (c.animation.isJumping()) return; // 跳跃中不重复触发
|
||||
c.animation.startJump();
|
||||
c.playerVelocity.y = c.jumpHeight;
|
||||
c.setOnGround(false); // 跳跃后设置为不在地面
|
||||
} else {
|
||||
this.space = false;
|
||||
if (c.isFlying) c.animation.setAnimationByPressed();
|
||||
}
|
||||
break;
|
||||
// 切换第一 / 第三人称视角
|
||||
case "toggleView":
|
||||
if (pressed) c.cam.changeView();
|
||||
break;
|
||||
// 切换飞行模式
|
||||
case "toggleFly":
|
||||
if (pressed && c.controllerMode === 0) {
|
||||
c.isFlying = !c.isFlying;
|
||||
if (c.isFlying) c.playerVelocity.set(0, 0, 0);
|
||||
c.animation.setAnimationByPressed();
|
||||
if (!c.isFlying && !c.playerIsOnGround) c.animation.startJump(true);
|
||||
}
|
||||
break;
|
||||
// 上 / 下车
|
||||
case "toggleVehicle":
|
||||
if (pressed) {
|
||||
if (c.isFlying) return;
|
||||
if (c.controllerMode === 0) c.vehicle.enter(); else c.vehicle.exit();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取最终移动轴:模拟输入优先,否则使用键盘八方向
|
||||
getMoveAxes() {
|
||||
const hasAnalogInput = this.analogMoveX !== 0 || this.analogMoveY !== 0;
|
||||
if (hasAnalogInput) return { x: this.analogMoveX, y: this.analogMoveY, isAnalog: true };
|
||||
return {
|
||||
x: Number(this.keyRgt) - Number(this.keyLft),
|
||||
y: Number(this.keyFwd) - Number(this.keyBkd),
|
||||
isAnalog: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 合并键盘与模拟输入,供动画和车辆等现有布尔逻辑使用
|
||||
private syncDirectionFlags() {
|
||||
const threshold = 0.2;
|
||||
this.fwd = this.keyFwd || this.analogMoveY > threshold;
|
||||
this.bkd = this.keyBkd || this.analogMoveY < -threshold;
|
||||
this.lft = this.keyLft || this.analogMoveX < -threshold;
|
||||
this.rgt = this.keyRgt || this.analogMoveX > threshold;
|
||||
}
|
||||
|
||||
// 键盘按下处理
|
||||
private onKeydown(e: KeyboardEvent) {
|
||||
const action = this.codeToAction.get(e.code);
|
||||
if (action) this.applyAction(action, true);
|
||||
}
|
||||
|
||||
// 键盘抬起处理
|
||||
private onKeyup(e: KeyboardEvent) {
|
||||
const action = this.codeToAction.get(e.code);
|
||||
if (action) this.applyAction(action, false);
|
||||
}
|
||||
|
||||
// 鼠标移动处理
|
||||
private onMouseMove(e: MouseEvent) {
|
||||
const lockTarget = this.ctrl.controls.domElement;
|
||||
if (lockTarget && document.pointerLockElement === lockTarget) {
|
||||
this.ctrl.cam.setToward(e.movementX, e.movementY, 0.0001);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import type { playerController } from "../PlayerController";
|
||||
|
||||
const HIT_COLOR = 0x66ff66;
|
||||
const FAR_HIT_COLOR = 0xffcc33;
|
||||
const MISS_COLOR = 0xff5555;
|
||||
const SENSOR_IDLE_COLOR = 0x45d7ff;
|
||||
|
||||
/** 绘制玩家胶囊与地面探测结果。 */
|
||||
export class PlayerDebug {
|
||||
private visible = false;
|
||||
private hasGroundProbe = false;
|
||||
private groundProbeHasHit = false;
|
||||
private groundProbeMaxDistance = 0;
|
||||
private groundProbeNear = 0;
|
||||
private groundRay: THREE.Line | null = null;
|
||||
private groundHit: THREE.Mesh | null = null;
|
||||
private groundProbePoint = new THREE.Vector3();
|
||||
private hasGroundSensor = false;
|
||||
private groundSensorActive = false;
|
||||
private groundSensorHasHit = false;
|
||||
private groundSensorMaxDistance = 0;
|
||||
private groundSensorRadius = 0;
|
||||
private groundSensor: THREE.Mesh | null = null;
|
||||
private readonly probeLocalPoint = new THREE.Vector3();
|
||||
private readonly capsuleLocalDown = new THREE.Vector3(0, -1, 0);
|
||||
private debugRoot: THREE.Group | null = null;
|
||||
|
||||
constructor(private readonly ctrl: playerController) { }
|
||||
|
||||
/** 返回人物调试开关。 */
|
||||
isVisible(): boolean {
|
||||
return this.visible;
|
||||
}
|
||||
|
||||
/** 切换人物胶囊与地面探测调试。 */
|
||||
setVisible(visible: boolean): void {
|
||||
this.visible = visible;
|
||||
this.syncVisibility();
|
||||
}
|
||||
|
||||
/** 在玩家模型或控制模式变化后重新同步显隐。 */
|
||||
syncVisibility(): void {
|
||||
const capsule = this.ctrl.playerCapsule;
|
||||
if (capsule) {
|
||||
const materials = Array.isArray(capsule.material) ? capsule.material : [capsule.material];
|
||||
for (const material of materials) material.visible = this.visible;
|
||||
}
|
||||
|
||||
const showProbe = this.visible && this.hasGroundProbe && this.ctrl.controllerMode === 0;
|
||||
const showSensor = this.visible && this.hasGroundSensor && this.ctrl.controllerMode === 0;
|
||||
if (this.groundRay) this.groundRay.visible = showProbe;
|
||||
if (this.groundHit) this.groundHit.visible = showProbe && this.groundProbeHasHit;
|
||||
if (this.groundSensor) this.groundSensor.visible = showSensor;
|
||||
if (showProbe) this.renderGroundProbe();
|
||||
if (showSensor) this.renderGroundSensor();
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动结束后刷新调试体姿态,使射线/体积传感区跟随胶囊实时位置。
|
||||
*/
|
||||
refreshTransforms(): void {
|
||||
if (!this.visible || this.ctrl.controllerMode !== 0) return;
|
||||
if (this.hasGroundProbe) this.renderGroundProbe();
|
||||
if (this.hasGroundSensor) this.renderGroundSensor();
|
||||
}
|
||||
|
||||
/** 写入移动系统本帧实际使用的中心地面探测结果。 */
|
||||
updateGroundProbe(
|
||||
origin: THREE.Vector3,
|
||||
hitPoint: THREE.Vector3 | null,
|
||||
maxDistance: number,
|
||||
near = 0,
|
||||
): void {
|
||||
this.hasGroundProbe = true;
|
||||
this.groundProbeHasHit = hitPoint !== null;
|
||||
this.groundProbeMaxDistance = Math.max(0, maxDistance);
|
||||
this.groundProbeNear = Math.max(0, near);
|
||||
if (hitPoint) this.groundProbePoint.copy(hitPoint);
|
||||
else this.groundProbePoint.copy(origin).y -= this.groundProbeMaxDistance;
|
||||
}
|
||||
|
||||
/** 写入胶囊下方体积地面传感区的当前状态。 */
|
||||
updateGroundSensor(
|
||||
_start: THREE.Vector3,
|
||||
_down: THREE.Vector3,
|
||||
maxDistance: number,
|
||||
radius: number,
|
||||
active: boolean,
|
||||
hasHit: boolean,
|
||||
): void {
|
||||
this.hasGroundSensor = maxDistance > 0 && radius > 0;
|
||||
this.groundSensorActive = active;
|
||||
this.groundSensorHasHit = hasHit;
|
||||
this.groundSensorMaxDistance = Math.max(0, maxDistance);
|
||||
this.groundSensorRadius = Math.max(0, radius);
|
||||
}
|
||||
|
||||
/** 清除当前地面探测显示。 */
|
||||
clearGroundProbe(): void {
|
||||
this.hasGroundProbe = false;
|
||||
this.hasGroundSensor = false;
|
||||
if (this.groundRay) this.groundRay.visible = false;
|
||||
if (this.groundHit) this.groundHit.visible = false;
|
||||
if (this.groundSensor) this.groundSensor.visible = false;
|
||||
}
|
||||
|
||||
/** 释放人物调试几何与材质。 */
|
||||
dispose(): void {
|
||||
if (this.debugRoot) {
|
||||
this.debugRoot.removeFromParent();
|
||||
this.debugRoot.traverse((obj) => {
|
||||
const mesh = obj as THREE.Mesh;
|
||||
mesh.geometry?.dispose();
|
||||
const mat = mesh.material;
|
||||
if (mat) {
|
||||
if (Array.isArray(mat)) mat.forEach(m => m.dispose());
|
||||
else mat.dispose();
|
||||
}
|
||||
});
|
||||
this.debugRoot = null;
|
||||
}
|
||||
this.groundRay = null;
|
||||
this.groundHit = null;
|
||||
this.groundSensor = null;
|
||||
this.hasGroundProbe = false;
|
||||
this.hasGroundSensor = false;
|
||||
}
|
||||
|
||||
private ensureDebugRoot(): THREE.Group | null {
|
||||
const capsule = this.ctrl.playerCapsule;
|
||||
if (!capsule) return null;
|
||||
|
||||
if (!this.debugRoot) {
|
||||
this.debugRoot = new THREE.Group();
|
||||
this.debugRoot.name = "playerDebugRoot";
|
||||
capsule.add(this.debugRoot);
|
||||
} else if (this.debugRoot.parent !== capsule) {
|
||||
capsule.add(this.debugRoot);
|
||||
}
|
||||
return this.debugRoot;
|
||||
}
|
||||
|
||||
/** 按当前探测结果更新线段、命中点和颜色。 */
|
||||
private renderGroundProbe(): void {
|
||||
const capsule = this.ctrl.playerCapsule;
|
||||
const root = this.ensureDebugRoot();
|
||||
if (!capsule || !root) return;
|
||||
|
||||
this.ensureGroundProbeObjects(root);
|
||||
if (!this.groundRay || !this.groundHit) return;
|
||||
|
||||
this.worldToCapsuleLocal(this.groundProbePoint, this.probeLocalPoint);
|
||||
const positions = this.groundRay.geometry.getAttribute("position") as THREE.BufferAttribute;
|
||||
positions.setXYZ(0, 0, 0, 0);
|
||||
positions.setXYZ(1, this.probeLocalPoint.x, this.probeLocalPoint.y, this.probeLocalPoint.z);
|
||||
positions.needsUpdate = true;
|
||||
|
||||
const distance = capsule.position.y - this.groundProbePoint.y;
|
||||
const hitInRange = this.groundProbeHasHit
|
||||
&& distance >= this.groundProbeNear
|
||||
&& distance <= this.groundProbeMaxDistance;
|
||||
const color = hitInRange ? HIT_COLOR : this.groundProbeHasHit ? FAR_HIT_COLOR : MISS_COLOR;
|
||||
(this.groundRay.material as THREE.LineBasicMaterial).color.setHex(color);
|
||||
this.groundRay.visible = true;
|
||||
|
||||
this.groundHit.visible = this.groundProbeHasHit;
|
||||
if (this.groundProbeHasHit) {
|
||||
this.groundHit.position.copy(this.probeLocalPoint);
|
||||
const sy = Math.max(1e-6, capsule.scale.y || 1);
|
||||
const localRadius = (capsule.capsuleInfo?.radius ?? 0) / sy;
|
||||
this.groundHit.scale.setScalar(Math.max(1e-6, localRadius * 0.05));
|
||||
(this.groundHit.material as THREE.MeshBasicMaterial).color.setHex(color);
|
||||
}
|
||||
}
|
||||
|
||||
/** 按当前状态更新体积地面传感区(锚定在胶囊 segment.end)。 */
|
||||
private renderGroundSensor(): void {
|
||||
const capsule = this.ctrl.playerCapsule;
|
||||
const root = this.ensureDebugRoot();
|
||||
const segEnd = capsule?.capsuleInfo?.segment.end;
|
||||
if (!capsule || !root || !segEnd) return;
|
||||
|
||||
this.ensureGroundSensorObject(root);
|
||||
if (!this.groundSensor) return;
|
||||
|
||||
const distance = Math.max(this.groundSensorMaxDistance, 1e-6);
|
||||
this.groundSensor.position.copy(segEnd).addScaledVector(this.capsuleLocalDown, distance * 0.5);
|
||||
this.groundSensor.quaternion.identity();
|
||||
this.groundSensor.scale.set(this.groundSensorRadius, distance, this.groundSensorRadius);
|
||||
|
||||
const material = this.groundSensor.material as THREE.MeshBasicMaterial;
|
||||
material.color.setHex(
|
||||
this.groundSensorActive
|
||||
? this.groundSensorHasHit ? HIT_COLOR : MISS_COLOR
|
||||
: SENSOR_IDLE_COLOR,
|
||||
);
|
||||
material.opacity = this.groundSensorActive ? 0.75 : 0.4;
|
||||
this.groundSensor.visible = this.hasGroundSensor;
|
||||
}
|
||||
|
||||
private worldToCapsuleLocal(world: THREE.Vector3, target: THREE.Vector3): THREE.Vector3 {
|
||||
target.copy(world);
|
||||
this.ctrl.playerCapsule.worldToLocal(target);
|
||||
return target;
|
||||
}
|
||||
|
||||
/** 延迟创建地面射线和命中标记。 */
|
||||
private ensureGroundProbeObjects(root: THREE.Group): void {
|
||||
if (!this.groundRay) {
|
||||
this.groundRay = new THREE.Line(
|
||||
new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]),
|
||||
new THREE.LineBasicMaterial({
|
||||
color: MISS_COLOR,
|
||||
depthTest: true,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
}),
|
||||
);
|
||||
this.groundRay.name = "playerGroundRayDebug";
|
||||
this.groundRay.renderOrder = 30;
|
||||
this.groundRay.frustumCulled = false;
|
||||
root.add(this.groundRay);
|
||||
} else if (this.groundRay.parent !== root) {
|
||||
root.add(this.groundRay);
|
||||
}
|
||||
|
||||
if (!this.groundHit) {
|
||||
this.groundHit = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(2, 10, 8),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: HIT_COLOR,
|
||||
wireframe: true,
|
||||
depthTest: true,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
}),
|
||||
);
|
||||
this.groundHit.name = "playerGroundRayHitDebug";
|
||||
this.groundHit.renderOrder = 31;
|
||||
this.groundHit.frustumCulled = false;
|
||||
root.add(this.groundHit);
|
||||
} else if (this.groundHit.parent !== root) {
|
||||
root.add(this.groundHit);
|
||||
}
|
||||
}
|
||||
|
||||
/** 延迟创建体积地面传感区线框。 */
|
||||
private ensureGroundSensorObject(root: THREE.Group): void {
|
||||
if (this.groundSensor) {
|
||||
if (this.groundSensor.parent !== root) root.add(this.groundSensor);
|
||||
return;
|
||||
}
|
||||
this.groundSensor = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(1, 1, 1, 16, 1, true),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: SENSOR_IDLE_COLOR,
|
||||
wireframe: true,
|
||||
depthTest: true,
|
||||
depthWrite: true,
|
||||
transparent: true,
|
||||
opacity: 0.4,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
);
|
||||
this.groundSensor.name = "playerGroundVolumeDebug";
|
||||
this.groundSensor.renderOrder = 29;
|
||||
this.groundSensor.frustumCulled = false;
|
||||
root.add(this.groundSensor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,833 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user