chore(controller): three-player-controller als Ordner statt Submodul
Das Submodul zeigte auf GitHub, der eingetragene Stand 028c652 lag aber nur lokal vor: GitHub-Master ac39682 plus Showcase mit Figuren und launch.json. Frische Klone konnten den Controller deshalb nicht laden. Jetzt liegt dieser Stand als normaler Ordner im Repo, ohne Verweis auf GitHub. dist/ und node_modules/ im Controller bleiben ignoriert und werden wie bisher lokal gebaut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
780e246fe5
commit
fb38652e7f
153 files changed
+42561
-4
No files matched your search
@@ -0,0 +1,330 @@
|
||||
import { MathUtils, Quaternion, Vector3, AnimationMixer, AnimationClip, LoopOnce, LoopRepeat } from "three";
|
||||
import { playerController } from "../../../src/PlayerController";
|
||||
import { SpineIK } from "./spineIK.js";
|
||||
|
||||
const spineBoneNames = ["mixamorigSpine", "mixamorigSpine1", "mixamorigSpine2"];
|
||||
|
||||
// 运动状态集合(用于判断 isMoving)
|
||||
const locomotionStates = new Set([
|
||||
"idle",
|
||||
"walking",
|
||||
"walking_backward",
|
||||
"left_walking",
|
||||
"right_walking",
|
||||
"running",
|
||||
"jumping",
|
||||
"flyidle",
|
||||
"flying",
|
||||
]);
|
||||
|
||||
// 允许执行战斗逻辑的运动状态(上半身分离后,奔跑时也可开火/换弹)
|
||||
const combatAllowedLocomotion = new Set([
|
||||
"idle", "walking", "walking_backward", "left_walking", "right_walking", "running",
|
||||
]);
|
||||
|
||||
const minPitchAngle = -Math.PI * (60 / 180); // 俯仰最小角(向下)
|
||||
const maxPitchAngle = Math.PI * (40 / 180); // 俯仰最大角(向上)
|
||||
|
||||
export class LocalPlayer {
|
||||
constructor({ scene, camera, controls }) {
|
||||
// ==================== 场景引用 ====================
|
||||
this._scene = scene;
|
||||
this._camera = camera;
|
||||
this._controls = controls;
|
||||
|
||||
// ==================== 核心对象 ====================
|
||||
this._player = null; // playerController 实例
|
||||
this.spineIK = null; // 脊椎 IK 实例
|
||||
|
||||
// ==================== 运动状态 ====================
|
||||
this.pitchTarget1P = 0; // 第一人称俯仰累积值
|
||||
this.isMoving = false; // 当前帧是否处于移动状态(由 onAnimationChange 更新)
|
||||
this._locomotionState = "idle"; // 当前运动动画状态名
|
||||
|
||||
// ==================== 配置 ====================
|
||||
this._mouseSensitivity = 5;
|
||||
this._firstPersonPitchOffset = 0; // 第一人称相机俯仰初始偏移
|
||||
|
||||
// ==================== 外部注入 ====================
|
||||
this._isGunEngagedFn = null; // 由 WeaponController 注入,判断是否持枪
|
||||
|
||||
// ==================== 上半身动画层 ====================
|
||||
this._upperMixer = null; // 上半身专用 AnimationMixer(root = 模型根节点)
|
||||
this._upperBodyBoneNames = null; // 脊椎以上所有骨骼名称集合,用于过滤 partial clip
|
||||
this._upperBodyBones = null; // 脊椎以上骨骼引用数组(缓存,避免每帧 getObjectByName)
|
||||
this._upperBoneSnapshots = null; // 主 mixer 骨骼值快照
|
||||
this._upperActions = new Map(); // key → AnimationAction
|
||||
this._upperState = null; // 当前上半身动作
|
||||
|
||||
// ==================== 走路瞄准修正 ====================
|
||||
this._idleHipsQ = null; // 静止时保存的 hips 本地四元数,用于走路时抵消 hips 走路偏移
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
// 初始化 playerController、骨骼 IK、事件回调
|
||||
async init(config) {
|
||||
const { mouseSensitivity = 5, ...rest } = config;
|
||||
this._mouseSensitivity = mouseSensitivity;
|
||||
this._firstPersonPitchOffset = config.playerModelConfig?.firstPersonPitchOffset
|
||||
?? this._firstPersonPitchOffset;
|
||||
|
||||
this._player = new playerController();
|
||||
await this._player.init({
|
||||
scene: this._scene,
|
||||
camera: this._camera,
|
||||
controls: this._controls,
|
||||
mouseSensitivity,
|
||||
...rest,
|
||||
});
|
||||
|
||||
// 绑定脊椎 & 头部骨骼
|
||||
const model = this._player.getPlayerModel();
|
||||
const spineBones = spineBoneNames
|
||||
.map((n) => model?.getObjectByName(n))
|
||||
.filter(Boolean);
|
||||
const headBoneName = config.playerModelConfig?.headBoneName;
|
||||
const headBone = model?.getObjectByName(headBoneName) ?? null;
|
||||
this.spineIK = new SpineIK(spineBones, headBone);
|
||||
|
||||
// 上半身 mixer:root = 模型根节点(与主 mixer 一致,路径解析最可靠)
|
||||
// 通过 partial clip(只含脊椎以上骨骼的 track)来限制写入范围,
|
||||
// 主 mixer 更新后再更新 upper mixer,后者覆写脊椎以上骨骼,下半身保持 locomotion 值。
|
||||
if (spineBones.length > 0) {
|
||||
this._upperBodyBoneNames = new Set();
|
||||
this._upperBodyBones = [];
|
||||
spineBones[0].traverse(b => {
|
||||
this._upperBodyBoneNames.add(b.name);
|
||||
this._upperBodyBones.push(b);
|
||||
});
|
||||
// 预分配快照数组,避免每帧 GC
|
||||
this._upperBoneSnapshots = this._upperBodyBones.map(() => new Quaternion());
|
||||
this._upperMixer = new AnimationMixer(model);
|
||||
}
|
||||
|
||||
// 监听动画切换,更新 isMoving
|
||||
this._player.onAnimationChange = (name) => {
|
||||
if (locomotionStates.has(name)) this._locomotionState = name;
|
||||
this.isMoving =
|
||||
name === "walking" ||
|
||||
name === "left_walking" ||
|
||||
name === "right_walking" ||
|
||||
name === "walking_backward" ||
|
||||
name === "running";
|
||||
};
|
||||
|
||||
// 接管第一人称鼠标移动
|
||||
this._player.onTowardChange = (dx, dy, speed) => {
|
||||
if (!this._player.getIsFirstPerson()) return;
|
||||
|
||||
// 水平朝向
|
||||
this._player.getPlayerCapsule().rotateY(
|
||||
-dx * speed * this._mouseSensitivity
|
||||
);
|
||||
|
||||
// 俯仰角累积
|
||||
this.pitchTarget1P = MathUtils.clamp(
|
||||
this.pitchTarget1P + (-dy * speed * this._mouseSensitivity),
|
||||
minPitchAngle,
|
||||
maxPitchAngle
|
||||
);
|
||||
|
||||
// 未持枪时直接驱动相机
|
||||
if (!this._isGunEngagedFn?.()) {
|
||||
this._camera.rotation.x = MathUtils.clamp(
|
||||
this._camera.rotation.x + (-dy * speed * this._mouseSensitivity),
|
||||
minPitchAngle,
|
||||
maxPitchAngle
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 视角切换
|
||||
this._player.onViewChange = (isFirstPerson) => {
|
||||
if (isFirstPerson) {
|
||||
if (headBoneName) {
|
||||
console.log(headBoneName);
|
||||
this._camera.position.z = 8;
|
||||
this._camera.position.x = 15;
|
||||
} else {
|
||||
this._camera.position.z = 0;
|
||||
this._camera.position.x = 0;
|
||||
}
|
||||
this._camera.rotation.x = this._firstPersonPitchOffset;
|
||||
this._player.setEnableToward(false);
|
||||
// 同步控制器俯仰角
|
||||
const targetPolar = this._controls.getPolarAngle() - Math.PI / 2 + Math.PI * (7.5 / 180);
|
||||
this.pitchTarget1P = targetPolar;
|
||||
// 航向角偏移
|
||||
this._player.getPlayerCapsule().rotateY(-Math.PI * (17 / 180));
|
||||
} else {
|
||||
this._player.setEnableToward(true);
|
||||
// 持枪瞄准状态下刷新一次动画,保证骨骼复原
|
||||
if (this._player.getCurrentPlayerAnimationName().includes("rifle_idle_aim")) {
|
||||
this._player.playAnimation("idle");
|
||||
}
|
||||
// 同步第一人称俯仰角
|
||||
const targetPolar = Math.PI / 2 + this.pitchTarget1P - Math.PI * (7.5 / 180);
|
||||
this._controls.minPolarAngle = targetPolar;
|
||||
this._controls.maxPolarAngle = targetPolar;
|
||||
this._controls.update();
|
||||
this._controls.minPolarAngle = minPitchAngle + Math.PI / 2;
|
||||
this._controls.maxPolarAngle = maxPitchAngle + Math.PI / 2;
|
||||
// 航向角偏移
|
||||
const delta = Math.PI * (17 / 180);
|
||||
const offset = this._camera.position.clone().sub(this._controls.target);
|
||||
offset.applyAxisAngle(new Vector3(0, 1, 0), delta);
|
||||
this._camera.position.copy(this._controls.target).add(offset);
|
||||
this._controls.update();
|
||||
}
|
||||
};
|
||||
|
||||
// 限制第三人称俯仰角
|
||||
this._controls.minPolarAngle = minPitchAngle + Math.PI / 2;
|
||||
this._controls.maxPolarAngle = maxPitchAngle + Math.PI / 2;
|
||||
}
|
||||
|
||||
// ==================== 外部注入 ====================
|
||||
|
||||
// 由 WeaponController 注入,让 1P 俯仰驱动能感知持枪状态
|
||||
setGunEngagedGetter(fn) {
|
||||
this._isGunEngagedFn = fn;
|
||||
}
|
||||
|
||||
// ==================== 主循环 ====================
|
||||
|
||||
// 每帧驱动动画与物理
|
||||
// dt 须由主循环传入,上半身 mixer 在主 mixer 之后更新才能正确覆写骨骼
|
||||
update(dt) {
|
||||
this._player?.update(dt);
|
||||
|
||||
if (this._upperMixer && dt != null) {
|
||||
const ua = this._upperState;
|
||||
if (!ua) return;
|
||||
|
||||
// Three.js PropertyMixer.apply() 有变更检测优化:仅当 accu0 ≠ accu1 时
|
||||
// 才调用 setValue() 写入骨骼。对于完全相同帧的动画(两帧值一致),
|
||||
// 两个 accu buffer 永远相等,导致 setValue() 被跳过,主 mixer 的
|
||||
// locomotion 动画透过来。修复:每帧更新前把两个 accu buffer 填为 NaN,
|
||||
// 使比较永远不相等,强制 setValue() 每帧执行,确保上半身覆盖生效。
|
||||
if (ua._propertyBindings) {
|
||||
for (const pm of ua._propertyBindings) {
|
||||
if (pm?.buffer) {
|
||||
const s = pm.valueSize;
|
||||
pm.buffer.fill(NaN, s, s * 3); // dirty accu0 + accu1
|
||||
}
|
||||
}
|
||||
}
|
||||
this._upperMixer.update(dt);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 上半身动画层 ====================
|
||||
|
||||
// 从指定 clip 的 t=0 帧直接读取 hips 四元数
|
||||
initIdleHipsQ(clipName) {
|
||||
const clip = this._player?.animation?.clips?.find(c => c.name === clipName);
|
||||
if (!clip) { console.warn(`initIdleHipsQ: 找不到 "${clipName}"`); return; }
|
||||
const track = clip.tracks.find(t => t.name === 'mixamorigHips.quaternion');
|
||||
if (!track || track.values.length < 4) return;
|
||||
this._idleHipsQ = new Quaternion(track.values[0], track.values[1], track.values[2], track.values[3]);
|
||||
}
|
||||
|
||||
// 在上半身 mixer 上注册一个动画
|
||||
registerUpperAnimation(key, clipName, opts = {}) {
|
||||
if (!this._upperMixer || !this._upperBodyBoneNames) return;
|
||||
const clips = this._player?.animation?.clips;
|
||||
if (!clips) return;
|
||||
const clip = clips.find(c => c.name === clipName);
|
||||
if (!clip) { console.warn(`registerUpperAnimation: 找不到 "${clipName}"`); return; }
|
||||
|
||||
// 只保留脊椎以上骨骼的 track,其余 track(hips、腿部等)丢弃
|
||||
const upperTracks = clip.tracks.filter(t => {
|
||||
const boneName = t.name.split('.')[0];
|
||||
return this._upperBodyBoneNames.has(boneName);
|
||||
});
|
||||
const partialClip = new AnimationClip(clip.name + '_upper_' + key, clip.duration, upperTracks);
|
||||
|
||||
const action = this._upperMixer.clipAction(partialClip);
|
||||
action.setLoop(opts.loop === false ? LoopOnce : LoopRepeat, Infinity);
|
||||
action.clampWhenFinished = opts.clampWhenFinished ?? false;
|
||||
const ts = opts.duration ? clip.duration / opts.duration : (opts.timeScale ?? 1);
|
||||
action.setEffectiveTimeScale(ts);
|
||||
action.enabled = true;
|
||||
action.setEffectiveWeight(0);
|
||||
this._upperActions.set(key, action);
|
||||
|
||||
if (opts.onFinished) {
|
||||
this._upperMixer.addEventListener("finished", (ev) => {
|
||||
if (ev.action === action) opts.onFinished();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 播放上半身动画(仅覆写脊椎以上骨骼)
|
||||
playUpperBody(key, opts = {}) {
|
||||
if (!this._upperMixer) return;
|
||||
const next = this._upperActions.get(key);
|
||||
if (!next) { console.warn(`playUpperBody: "${key}" 未注册`); return; }
|
||||
|
||||
const fade = opts.fade ?? 0.18;
|
||||
const prev = this._upperState;
|
||||
|
||||
if (!opts.force && prev === next) return;
|
||||
|
||||
// 直接设为 1,从第一帧起就以满权重覆盖主 mixer。
|
||||
if (prev && prev !== next) prev.fadeOut(fade);
|
||||
|
||||
next.reset();
|
||||
next.setEffectiveWeight(1);
|
||||
next.play();
|
||||
|
||||
this._upperState = next;
|
||||
}
|
||||
|
||||
// 走路/奔跑时修正 spine[0] 四元数,抵消 hips 走路旋转偏移
|
||||
// 使 spine0 的世界朝向等效于静止时(idle hips × spine_local),
|
||||
applyHipsCorrection() {
|
||||
if (!this._idleHipsQ || !this.spineIK?.spineBones?.length) return;
|
||||
const hipsBone = this._player?.getPlayerModel()?.getObjectByName("mixamorigHips");
|
||||
if (!hipsBone) return;
|
||||
|
||||
// correction = hips_walk_local⁻¹ × hips_idle_local
|
||||
const correction = new Quaternion().copy(hipsBone.quaternion).invert().multiply(this._idleHipsQ);
|
||||
const spine0 = this.spineIK.spineBones[0];
|
||||
spine0.quaternion.premultiply(correction);
|
||||
spine0.updateWorldMatrix(false, false);
|
||||
}
|
||||
|
||||
// 停止上半身动画,让下半身(全身)动画完全接管
|
||||
stopUpperBody(fade = 0.18) {
|
||||
if (!this._upperState) return;
|
||||
this._upperState.fadeOut(fade);
|
||||
this._upperState = null;
|
||||
}
|
||||
|
||||
// ==================== 工具方法 ====================
|
||||
|
||||
isCombatLocomotionAllowed() { return combatAllowedLocomotion.has(this._locomotionState); }
|
||||
|
||||
// ==================== playerController 代理 ====================
|
||||
|
||||
getIsFirstPerson() { return this._player?.getIsFirstPerson() ?? false; }
|
||||
getIsFlying() { return this._player?.getIsFlying() ?? false; }
|
||||
getPosition() { return this._player?.getPosition?.() ?? null; }
|
||||
getFirstPersonPitchOffset() { return this._firstPersonPitchOffset; }
|
||||
getPlayerModel() { return this._player?.getPlayerModel(); }
|
||||
getColliderMeshes() { return this._player?.getColliderMeshes?.() ?? []; }
|
||||
getCenterScreenRaycastHit() { return this._player?.getCenterScreenRaycastHit() ?? null; }
|
||||
playAnimation(name, opts) { return this._player?.playAnimation(name, opts); }
|
||||
registerAnimation(key, clipName, opts) { return this._player?.registerAnimation(key, clipName, opts); }
|
||||
registerLocomotionSet(...a) { return this._player?.registerLocomotionSet(...a); }
|
||||
switchLocomotionSet(name) { return this._player?.switchLocomotionSet(name); }
|
||||
setMaxCamDistance(d) { return this._player?.setMaxCamDistance(d); }
|
||||
setPlayerSpeed(s) { return this._player?.setPlayerSpeed(s); }
|
||||
setEnableToward(v) { return this._player?.setEnableToward(v); }
|
||||
setThirdMouseMode(mode) { return this._player?.setThirdMouseMode(mode); }
|
||||
onAllEvent() { return this._player?.onAllEvent(); }
|
||||
offAllEvent() { return this._player?.offAllEvent(); }
|
||||
onViewChange(isFirstPerson) { return this._player?.onViewChange(isFirstPerson); }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Euler, Quaternion, Vector3 } from "three";
|
||||
|
||||
// 复用的临时量(避免每帧 GC)
|
||||
const _aimQ = new Quaternion();
|
||||
const _yawQ = new Quaternion();
|
||||
const _localPitchQ = new Quaternion();
|
||||
const _parentWorldQ = new Quaternion();
|
||||
const _cameraWorldQ = new Quaternion();
|
||||
const _aimAxis = new Vector3(1, 0, 0);
|
||||
const _yawAxis = new Vector3(0, 1, 0);
|
||||
|
||||
const _headWorldQ = new Quaternion();
|
||||
const _headParentWorldQ = new Quaternion();
|
||||
const _headEuler = new Euler();
|
||||
|
||||
// 脊椎骨骼 IK
|
||||
export class SpineIK {
|
||||
constructor(spineBones, headBone) {
|
||||
// ==================== 骨骼引用 ====================
|
||||
this.spineBones = spineBones; // 脊椎骨骼数组(从下到上)
|
||||
this.headBone = headBone; // 头部骨骼
|
||||
|
||||
// 每帧 IK 应用前保存的"干净动画姿态",下一帧先恢复再驱动
|
||||
this.baseQuats = spineBones.map(() => new Quaternion());
|
||||
}
|
||||
|
||||
// ==================== 骨骼保存 / 恢复 ====================
|
||||
|
||||
// 将脊椎骨骼恢复到上一帧保存的干净动画姿态
|
||||
// 在 player.update()(动画驱动)之前调用,防止 IK 残留影响动画混合
|
||||
restoreBones() {
|
||||
for (let i = 0; i < this.spineBones.length; i++) {
|
||||
this.spineBones[i].quaternion.copy(this.baseQuats[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// 清除头骨骼的横滚(roll),防止第一人称下相机随动画倾斜
|
||||
clearHeadRoll() {
|
||||
const head = this.headBone;
|
||||
if (!head) return;
|
||||
head.updateWorldMatrix(true, false);
|
||||
head.getWorldQuaternion(_headWorldQ);
|
||||
_headEuler.setFromQuaternion(_headWorldQ, "YXZ");
|
||||
_headEuler.z = 0;
|
||||
_headWorldQ.setFromEuler(_headEuler);
|
||||
head.parent.getWorldQuaternion(_headParentWorldQ);
|
||||
head.quaternion.copy(_headParentWorldQ).invert().multiply(_headWorldQ);
|
||||
head.updateWorldMatrix(false, false);
|
||||
}
|
||||
|
||||
// ==================== IK 应用 ====================
|
||||
|
||||
// 第一人称脊椎俯仰 IK
|
||||
// 将 pitchTarget 均分到每根脊椎骨骼,使持枪手臂跟随鼠标上下瞄准
|
||||
applyAim1P(camera, pitchTarget) {
|
||||
if (!this.spineBones.length) return;
|
||||
this.clearHeadRoll();
|
||||
|
||||
camera.getWorldQuaternion(_cameraWorldQ);
|
||||
_aimAxis.set(1, 0, 0).applyQuaternion(_cameraWorldQ);
|
||||
_aimQ.setFromAxisAngle(_aimAxis, pitchTarget / this.spineBones.length);
|
||||
|
||||
this.spineBones[0].parent.updateWorldMatrix(true, false);
|
||||
for (let i = 0; i < this.spineBones.length; i++) {
|
||||
this.baseQuats[i].copy(this.spineBones[i].quaternion); // 保存干净姿态
|
||||
// 转到父骨骼局部空间后叠加
|
||||
this.spineBones[i].parent.getWorldQuaternion(_parentWorldQ);
|
||||
_localPitchQ.copy(_parentWorldQ).invert().multiply(_aimQ).multiply(_parentWorldQ);
|
||||
this.spineBones[i].quaternion.premultiply(_localPitchQ);
|
||||
this.spineBones[i].updateWorldMatrix(false, false);
|
||||
}
|
||||
// 传播到头骨骼,让相机挂点跟随
|
||||
this.spineBones[this.spineBones.length - 1].updateWorldMatrix(false, true);
|
||||
}
|
||||
|
||||
// 第三人称脊椎俯仰 + 偏航 IK,同时对 camera.rotation.x 做视觉补偿偏移
|
||||
applyAim3P(camera, isGunEngaged) {
|
||||
if (!this.spineBones.length) return;
|
||||
|
||||
// 归一化俯仰(-1 ~ +1),用于二次曲线补偿
|
||||
const normalizedPitch = camera.rotation.x / (Math.PI * (50 / 180));
|
||||
const pitchSq = Math.pow(Math.abs(normalizedPitch), 2.0);
|
||||
|
||||
const pitchTarget = isGunEngaged ? camera.rotation.x : 0;
|
||||
// 相机视角补偿:向下看时额外抬高,向上看时额外压低,使瞄准更自然
|
||||
camera.rotation.x += normalizedPitch > 0 ? pitchSq * 0.35 : -pitchSq * 0.1;
|
||||
|
||||
// 人物朝向补偿:需要往屏幕中心方向旋转
|
||||
const yawTarget = isGunEngaged ? -Math.PI * ((10 * (1 + pitchSq * 0.35)) / 180) : 0;
|
||||
|
||||
_aimAxis.set(1, 0, 0).applyQuaternion(camera.quaternion);
|
||||
_aimQ.setFromAxisAngle(_aimAxis, pitchTarget / this.spineBones.length);
|
||||
_yawQ.setFromAxisAngle(_yawAxis, yawTarget);
|
||||
_aimQ.premultiply(_yawQ);
|
||||
|
||||
this.spineBones[0].parent.updateWorldMatrix(true, false);
|
||||
for (let i = 0; i < this.spineBones.length; i++) {
|
||||
this.baseQuats[i].copy(this.spineBones[i].quaternion);
|
||||
this.spineBones[i].parent.getWorldQuaternion(_parentWorldQ);
|
||||
_localPitchQ.copy(_parentWorldQ).invert().multiply(_aimQ).multiply(_parentWorldQ);
|
||||
this.spineBones[i].quaternion.premultiply(_localPitchQ);
|
||||
this.spineBones[i].updateWorldMatrix(false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user