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,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);
}
}
}