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>
331 lines
15 KiB
JavaScript
331 lines
15 KiB
JavaScript
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); }
|
||
}
|