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:
knsoandClaude Opus 5 committed 2026-09-15 21:27:03 +02:00
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;
}
}