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,113 @@
|
||||
import {
|
||||
Matrix3,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
Object3D,
|
||||
PlaneGeometry,
|
||||
TextureLoader,
|
||||
} from "three";
|
||||
|
||||
const _normalMatrix = new Matrix3(); // 法线矩阵
|
||||
const _decalHelper = new Object3D(); // 弹孔朝向辅助体
|
||||
|
||||
// 弹孔贴花系统(对象池)
|
||||
export class DecalSystem {
|
||||
constructor(scene, maxDecals = 60, decalSize = 0.02) {
|
||||
// ==================== 场景引用 ====================
|
||||
this._scene = scene;
|
||||
|
||||
// ==================== 对象池配置 ====================
|
||||
this._maxDecals = maxDecals; // 最大弹孔数量,超出后移除最旧的
|
||||
this._pool = []; // 场景中现存的弹孔 Mesh
|
||||
this._mats = []; // 可用的弹痕材质(多张随机选取)
|
||||
this._geo = new PlaneGeometry(decalSize, decalSize); // 弹孔平面几何体
|
||||
this.onSpawn = null; // (hitPoint, hitNormal) => void,多人模式注入广播回调
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
// 批量加载弹痕贴图,生成对应材质
|
||||
async loadMaterials(files, baseUrl) {
|
||||
const loader = new TextureLoader();
|
||||
await Promise.all(
|
||||
files.map(async (f) => {
|
||||
const tex = await loader.loadAsync(baseUrl + f);
|
||||
this._mats.push(
|
||||
new MeshBasicMaterial({
|
||||
map: tex,
|
||||
transparent: true,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -4, // 防止与墙面 Z-fighting
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 生成 / 清除 ====================
|
||||
|
||||
// 在射线命中点生成弹孔,并触发命中硝烟特效
|
||||
spawn(hit, effects) {
|
||||
if (!hit?.face || !hit?.object) return;
|
||||
if (!this._mats.length) return;
|
||||
|
||||
// 计算世界法线
|
||||
_normalMatrix.getNormalMatrix(hit.object.matrixWorld);
|
||||
const hitNormal = hit.face.normal.clone().applyMatrix3(_normalMatrix).normalize();
|
||||
const hitPoint = hit.point.clone();
|
||||
|
||||
// 计算弹孔朝向(面向法线 + 随机旋转)
|
||||
_decalHelper.position.copy(hitPoint);
|
||||
_decalHelper.lookAt(hitPoint.clone().add(hitNormal));
|
||||
_decalHelper.rotation.z = Math.random() * Math.PI * 2;
|
||||
|
||||
// 创建弹孔 Mesh,沿法线微偏移以避免 Z-fighting
|
||||
const mat = this._mats[Math.floor(Math.random() * this._mats.length)];
|
||||
const decal = new Mesh(this._geo, mat);
|
||||
decal.position.copy(hitPoint).addScaledVector(hitNormal, 0.001);
|
||||
decal.rotation.copy(_decalHelper.rotation);
|
||||
decal.renderOrder = 1;
|
||||
this._scene.add(decal);
|
||||
|
||||
// 超出上限时移除最旧弹孔
|
||||
this._pool.push(decal);
|
||||
if (this._pool.length > this._maxDecals) {
|
||||
this._scene.remove(this._pool.shift());
|
||||
}
|
||||
|
||||
// 命中硝烟特效
|
||||
effects?.triggerHitSmoke(hitPoint, hitNormal);
|
||||
|
||||
// 广播给其他客户端(多人模式注入)
|
||||
this.onSpawn?.(hitPoint, hitNormal);
|
||||
}
|
||||
|
||||
// 根据世界坐标和法线直接生成弹孔(接收远程弹痕时使用,不触发硝烟)
|
||||
spawnAtPoint(hitPoint, hitNormal) {
|
||||
if (!this._mats.length) return;
|
||||
|
||||
_decalHelper.position.copy(hitPoint);
|
||||
_decalHelper.lookAt(hitPoint.clone().add(hitNormal));
|
||||
_decalHelper.rotation.z = Math.random() * Math.PI * 2;
|
||||
|
||||
const mat = this._mats[Math.floor(Math.random() * this._mats.length)];
|
||||
const decal = new Mesh(this._geo, mat);
|
||||
decal.position.copy(hitPoint).addScaledVector(hitNormal, 0.001);
|
||||
decal.rotation.copy(_decalHelper.rotation);
|
||||
decal.renderOrder = 1;
|
||||
this._scene.add(decal);
|
||||
|
||||
this._pool.push(decal);
|
||||
if (this._pool.length > this._maxDecals) {
|
||||
this._scene.remove(this._pool.shift());
|
||||
}
|
||||
}
|
||||
|
||||
// 清空所有弹孔
|
||||
clear() {
|
||||
for (const d of this._pool) this._scene.remove(d);
|
||||
this._pool.length = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import { AnimationMixer, LoopOnce, Object3D, Quaternion, Vector3 } from "three";
|
||||
|
||||
export const MODE = Object.freeze({ NORMAL: "normal", PRIMARY: "primary" });
|
||||
|
||||
const _muzzleWorldPos = new Vector3(); // 枪口世界坐标
|
||||
|
||||
export class WeaponController {
|
||||
constructor({ scene, camera, localPlayer, decalSystem, effects, hud, zombieManager }) {
|
||||
// ==================== 场景引用 ====================
|
||||
this._scene = scene;
|
||||
this._camera = camera;
|
||||
this._player = localPlayer;
|
||||
this._decalSystem = decalSystem;
|
||||
this._effects = effects;
|
||||
this._hud = hud;
|
||||
this._zombieManager = zombieManager;
|
||||
|
||||
// ==================== 武器配置 ====================
|
||||
this._gunScale = 0.1; // 枪模型缩放
|
||||
this._gunPos = [1, 26.5, 2]; // 枪挂载到右手骨骼的位置偏移
|
||||
this._gunBarrelDir = new Vector3(0, 0, -1); // 枪管沿模型局部 -Z
|
||||
this._gunTargetDir = new Vector3(0, 1, 0); // 手骨 +Y = 手指方向
|
||||
this._gunRoll = Math.PI / 2;
|
||||
this._gunMuzzleOffset = [0, 80, -480]; // 枪口偏移 [左, 上, 前]
|
||||
|
||||
// ==================== 武器模型 ====================
|
||||
this._weaponModel = null; // 枪模型根节点
|
||||
this._muzzlePoint = null; // 枪口标记点(供特效定位)
|
||||
this._weaponMixer = null; // 武器动画混合器
|
||||
this._weaponReloadAction = null; // 换弹动画
|
||||
this._weaponShootAction = null; // 射击动画
|
||||
this._shotSound = null; // 射击音效
|
||||
this._shakeIntensity = 0; // 当前镜头抖动强度
|
||||
this._shakeDecay = 10; // 抖动衰减系数
|
||||
|
||||
// ==================== 模式 & 武器槽 ====================
|
||||
this._currentMode = MODE.NORMAL;
|
||||
this._weaponSlots = [
|
||||
{ key: "1", mode: MODE.PRIMARY, label: "Rifle" },
|
||||
{ key: "4", mode: MODE.NORMAL, label: "Fists" },
|
||||
];
|
||||
|
||||
// ==================== 相机距离限制 ====================
|
||||
this._normalMaxCam = 220; // 徒手模式最远距离
|
||||
this._armedMaxCam = 100; // 瞄准模式最远距离
|
||||
|
||||
// ==================== 移速配置 ====================
|
||||
this._baseSpeed = 300;
|
||||
this._armedSpeed = 240; // 持枪移速 = baseSpeed * 0.8
|
||||
|
||||
// ==================== 开火状态机 ====================
|
||||
this._isAiming = false; // 是否瞄准中(RMB 触发,含视角缩放)
|
||||
this._isSoftAiming = false; // 是否软瞄准中(单击开火触发,不缩放视角)
|
||||
this._isFiring = false; // 是否连射中
|
||||
this._isTriggerDown = false; // 鼠标左键是否按住
|
||||
this._isReloading = false; // 是否换弹中
|
||||
this._magSize = 30; // 弹夹容量
|
||||
this._currentAmmo = 30; // 当前子弹数
|
||||
this._totalAmmo = 300; // 总备用弹药量
|
||||
this._firstShotTimer = null; // 180ms 后必出一枪的计时器
|
||||
this._holdAimTimer = null; // 停火后 2s 才放枪的计时器
|
||||
this._lastFireTime = 0;
|
||||
this._elapsed = 0; // 由 update() 每帧更新,供 setTimeout 回调读取
|
||||
|
||||
this._reloadTimer1 = null; // 换弹半程计时器
|
||||
this._reloadTimer2 = null; // 换弹完成计时器
|
||||
this._RELOAD_DURATION_MS = 2000; // 换弹动画时长,需根据实际模型动画调整
|
||||
this._FIRE_RATE_S = 0.1; // 连射间隔(秒)
|
||||
this._FIRE_ANIM_FADE_MS = 180; // 与 playAnimation 默认 fade(0.18s) 对齐
|
||||
this._HOLD_AIM_DURATION = 2000; // 停火后保持瞄准状态时长(ms)
|
||||
|
||||
// ==================== 射线命中缓存 ====================
|
||||
this._frameHit = null; // 每帧射线命中结果
|
||||
|
||||
// ==================== 多人扩展 ====================
|
||||
this.onHitPlayer = null; // (playerId, damage) => void,由外部注入
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
// 加载武器模型并挂载到右手骨骼
|
||||
async load(gltfLoader, baseUrl) {
|
||||
const gltf = await gltfLoader.loadAsync(baseUrl + "./glb/ak47.glb");
|
||||
this._weaponModel = gltf.scene;
|
||||
|
||||
const person = this._player.getPlayerModel();
|
||||
const rightHand = person?.getObjectByName("mixamorigRightHand");
|
||||
if (!rightHand) {
|
||||
console.warn("[WeaponController] 未找到右手骨骼 mixamorigRightHand");
|
||||
return;
|
||||
}
|
||||
|
||||
this._weaponModel.scale.setScalar(this._gunScale);
|
||||
this._weaponModel.position.set(...this._gunPos);
|
||||
|
||||
this._magazineBone = null;
|
||||
this._weaponModel.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
child.frustumCulled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 找到备用弹夹骨骼,初始缩为 0 隐藏
|
||||
this._magazineBone = this._weaponModel.getObjectByName("Bone002_01");
|
||||
if (this._magazineBone) this._magazineBone.scale.setScalar(0.0001);
|
||||
|
||||
// 对齐枪管方向并附加滚转
|
||||
const alignQ = new Quaternion().setFromUnitVectors(this._gunBarrelDir, this._gunTargetDir);
|
||||
const rollQ = new Quaternion().setFromAxisAngle(this._gunTargetDir, this._gunRoll);
|
||||
this._weaponModel.quaternion.copy(rollQ.multiply(alignQ));
|
||||
|
||||
this._weaponModel.visible = false;
|
||||
rightHand.add(this._weaponModel);
|
||||
|
||||
// 枪口标记点(供特效定位)
|
||||
this._muzzlePoint = new Object3D();
|
||||
this._muzzlePoint.position.set(...this._gunMuzzleOffset);
|
||||
this._weaponModel.add(this._muzzlePoint);
|
||||
|
||||
// 初始化武器动画
|
||||
this._weaponMixer = new AnimationMixer(this._weaponModel);
|
||||
const reloadClip = gltf.animations.find(a => a.name === "Armature.003|reload");
|
||||
if (reloadClip) {
|
||||
this._weaponReloadAction = this._weaponMixer.clipAction(reloadClip);
|
||||
this._weaponReloadAction.setLoop(LoopOnce);
|
||||
// 关键修改:取消锁定在最后一帧。
|
||||
// 这样动画结束后,骨骼会自动通过 Mixer 的权重回归到 Bind Pose(初始插在枪上的位置)。
|
||||
this._weaponReloadAction.clampWhenFinished = false;
|
||||
this._weaponReloadAction.timeScale = 1.3;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 注册所有射击相关动画集
|
||||
setupAnimations() {
|
||||
this._player.registerLocomotionSet("primary", {
|
||||
idle: "rifle_idle",
|
||||
walking: "rifle_walk",
|
||||
running: "rifle_run",
|
||||
jumping: "rifle_jump",
|
||||
});
|
||||
this._player.registerLocomotionSet("primary_aim", {
|
||||
idle: "rifle_idle_aim3",
|
||||
walking: "rifle_walk",
|
||||
running: "rifle_run",
|
||||
jumping: "rifle_jump",
|
||||
});
|
||||
|
||||
// 上半身专用动画:仅覆写脊椎以上骨骼,下半身继续播放移动动画
|
||||
this._player.registerUpperAnimation("upper_shoot", "rifle_shoot3", { loop: true, timeScale: 0.5 });
|
||||
this._player.registerUpperAnimation("upper_reload", "reload", { loop: false, timeScale: 1.5 });
|
||||
this._player.registerUpperAnimation("upper_aim", "rifle_idle_aim3", { loop: true, timeScale: 0.5 });
|
||||
|
||||
this._player.initIdleHipsQ("rifle_idle_aim3");
|
||||
}
|
||||
|
||||
// 绑定键鼠输入
|
||||
bindInput() {
|
||||
document.addEventListener("mousedown", (e) => {
|
||||
if (!this._canRunCombatLogic()) return;
|
||||
if (e.button === 2 && !this._player.getIsFirstPerson()) {
|
||||
this.enterAim();
|
||||
}
|
||||
if (e.button === 0) {
|
||||
if (!this._firstShotTimer && !this._isFiring) this._triggerShootAnim();
|
||||
this._startFiring();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mouseup", (e) => {
|
||||
if (e.button === 2 && !this._player.getIsFirstPerson()) this.exitAim();
|
||||
if (e.button === 0) this._stopFiring();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === "1") this.switchMode(MODE.PRIMARY);
|
||||
if (k === "4") this.switchMode(MODE.NORMAL);
|
||||
if (k === "q") this.switchMode(this._currentMode === MODE.PRIMARY ? MODE.NORMAL : MODE.PRIMARY);
|
||||
if (k === "r") this.reload();
|
||||
});
|
||||
|
||||
document.addEventListener("wheel", (e) => {
|
||||
if (this._isReloading) return;
|
||||
const idx = this._weaponSlots.findIndex((s) => s.mode === this._currentMode);
|
||||
const next = (idx + (e.deltaY > 0 ? 1 : -1) + this._weaponSlots.length) % this._weaponSlots.length;
|
||||
this.switchMode(this._weaponSlots[next].mode);
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
// ==================== 主循环 ====================
|
||||
|
||||
// 每帧由 shooting.js 主循环调用
|
||||
update(elapsed, dt) {
|
||||
this._elapsed = elapsed;
|
||||
const canRunCombatLogic = this._canRunCombatLogic();
|
||||
|
||||
// 飞行时强制取消换弹
|
||||
if (this._currentMode === MODE.PRIMARY && this._isReloading && this._player.getIsFlying()) {
|
||||
this._cancelReload();
|
||||
}
|
||||
// 飞行/跳跃等不可战斗状态时强制中断武器逻辑(换弹中不打断,由换弹自己管)
|
||||
if (this._currentMode === MODE.PRIMARY && !canRunCombatLogic && !this._isReloading) {
|
||||
this._forceStopCombatLogic();
|
||||
}
|
||||
|
||||
// 第一人称:静止时自动进入瞄准,移动时退出
|
||||
if (this._currentMode === MODE.PRIMARY && this._player.getIsFirstPerson()) {
|
||||
if (canRunCombatLogic && !this._isAiming) this.enterAim();
|
||||
else if (!canRunCombatLogic && this._isAiming) this.exitAim();
|
||||
}
|
||||
|
||||
// 更新粒子特效
|
||||
this._effects?.update(dt);
|
||||
|
||||
// 更新武器动画混合器
|
||||
if (this._weaponMixer) this._weaponMixer.update(dt);
|
||||
|
||||
// 非换弹时每帧强制隐藏备用弹夹骨骼(mixer 更新后覆写)
|
||||
if (this._magazineBone && !this._isReloading) this._magazineBone.scale.setScalar(0.0001);
|
||||
|
||||
// 镜头抖动(指数衰减)
|
||||
if (this._shakeIntensity > 0.0001) {
|
||||
this._camera.rotation.x += (Math.random() - 0.5) * this._shakeIntensity;
|
||||
this._camera.rotation.y += (Math.random() - 0.5) * this._shakeIntensity * 0.4;
|
||||
this._shakeIntensity *= Math.exp(-this._shakeDecay * dt);
|
||||
} else {
|
||||
this._shakeIntensity = 0;
|
||||
}
|
||||
|
||||
// 更新射线命中缓存
|
||||
this._frameHit = this._player.getCenterScreenRaycastHit();
|
||||
|
||||
// 连射节拍
|
||||
if (canRunCombatLogic && this._isFiring && elapsed - this._lastFireTime >= this._FIRE_RATE_S) {
|
||||
if (this._currentAmmo <= 0) {
|
||||
this._stopFiring();
|
||||
// 只有总弹药大于0时才触发自动换弹
|
||||
if (this._totalAmmo > 0) this.reload();
|
||||
} else {
|
||||
this._lastFireTime = elapsed;
|
||||
this._fireOnce();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 状态查询 ====================
|
||||
|
||||
// 是否处于任意武器激活状态(等待首发 / 连射 / hold-aim 冷却 / 瞄准)
|
||||
// SpineIK 和主循环通过此方法判断是否需要驱动 IK
|
||||
isGunEngaged() {
|
||||
return (
|
||||
this._isAiming ||
|
||||
this._isSoftAiming ||
|
||||
this._isFiring ||
|
||||
this._isReloading ||
|
||||
this._firstShotTimer !== null ||
|
||||
this._holdAimTimer !== null
|
||||
);
|
||||
}
|
||||
|
||||
getMode() { return this._currentMode; }
|
||||
|
||||
resetAmmo() {
|
||||
this._currentAmmo = this._magSize;
|
||||
this._totalAmmo = 300;
|
||||
this._hud.updateAmmo?.(this._currentAmmo, this._totalAmmo);
|
||||
}
|
||||
|
||||
_canRunCombatLogic() {
|
||||
if (this._currentMode !== MODE.PRIMARY) return false;
|
||||
if (this._isReloading) return false;
|
||||
if (this._player.getIsFlying()) return false;
|
||||
if (typeof this._player.isCombatLocomotionAllowed === "function") {
|
||||
return this._player.isCombatLocomotionAllowed();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_forceStopCombatLogic() {
|
||||
this._isTriggerDown = false;
|
||||
if (this._firstShotTimer) {
|
||||
clearTimeout(this._firstShotTimer);
|
||||
this._firstShotTimer = null;
|
||||
}
|
||||
this._isFiring = false;
|
||||
if (this._weaponShootAction) this._weaponShootAction.stop();
|
||||
this._player.stopUpperBody(0.18);
|
||||
this._cancelHoldAimTimer();
|
||||
if (this._isAiming) this.exitAim();
|
||||
else {
|
||||
this._isSoftAiming = false;
|
||||
this._hud.hideCrosshair();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 模式切换 & 瞄准 ====================
|
||||
|
||||
switchMode(newMode) {
|
||||
if (this._currentMode === newMode || this._isReloading) return;
|
||||
|
||||
// 退出旧模式
|
||||
if (this._currentMode === MODE.PRIMARY) {
|
||||
this.exitAim();
|
||||
this._isSoftAiming = false;
|
||||
this._isTriggerDown = false;
|
||||
this._isFiring = false;
|
||||
if (this._weaponShootAction) this._weaponShootAction.stop();
|
||||
this._player.stopUpperBody(0.18);
|
||||
if (this._firstShotTimer) { clearTimeout(this._firstShotTimer); this._firstShotTimer = null; }
|
||||
this._cancelHoldAimTimer();
|
||||
this._hud.hideCrosshair();
|
||||
this._hud.hideAmmo?.();
|
||||
this._player.switchLocomotionSet("default");
|
||||
this._player.setMaxCamDistance(this._normalMaxCam);
|
||||
this._player.setPlayerSpeed(this._baseSpeed);
|
||||
}
|
||||
|
||||
this._currentMode = newMode;
|
||||
|
||||
// 进入新模式
|
||||
if (this._currentMode === MODE.PRIMARY) {
|
||||
this._player.switchLocomotionSet("primary");
|
||||
this._player.setPlayerSpeed(this._armedSpeed);
|
||||
this._hud.updateAmmo?.(this._currentAmmo, this._totalAmmo);
|
||||
this._hud.showAmmo?.();
|
||||
}
|
||||
|
||||
if (this._weaponModel) this._weaponModel.visible = (this._currentMode === MODE.PRIMARY);
|
||||
}
|
||||
|
||||
// 进入瞄准模式;soft=true 时为软瞄准,跳过视角缩放
|
||||
enterAim(soft = false) {
|
||||
if (soft) {
|
||||
if (this._isSoftAiming) return;
|
||||
this._isSoftAiming = true;
|
||||
} else {
|
||||
if (!this._canRunCombatLogic() || this._isAiming) return;
|
||||
this._isAiming = true;
|
||||
this._isSoftAiming = false;
|
||||
this._player.setMaxCamDistance(this._armedMaxCam);
|
||||
}
|
||||
this._player.switchLocomotionSet("primary_aim");
|
||||
this._hud.showCrosshair();
|
||||
this._player.playUpperBody("upper_aim", { fade: 0.18 });
|
||||
if (this._player.getIsFirstPerson()) {
|
||||
this._camera.rotation.x = this._player.getFirstPersonPitchOffset();
|
||||
}
|
||||
}
|
||||
|
||||
// 退出瞄准模式;soft=true 时为软瞄准,跳过视角还原
|
||||
exitAim(soft = false) {
|
||||
if (soft) {
|
||||
if (!this._isSoftAiming) return;
|
||||
this._isSoftAiming = false;
|
||||
} else {
|
||||
if (!this._isAiming) return;
|
||||
this._isAiming = false;
|
||||
this._player.setMaxCamDistance(this._normalMaxCam);
|
||||
this._cancelHoldAimTimer();
|
||||
}
|
||||
this._player.switchLocomotionSet(
|
||||
this._currentMode === MODE.PRIMARY ? "primary" : "default"
|
||||
);
|
||||
this._player.stopUpperBody(0.18);
|
||||
this._hud.hideCrosshair();
|
||||
if (this._player.getIsFirstPerson()) {
|
||||
this._camera.rotation.x = this._player.pitchTarget1P;
|
||||
}
|
||||
}
|
||||
|
||||
// 换弹
|
||||
reload() {
|
||||
if (this._currentMode !== MODE.PRIMARY || this._isReloading || this._totalAmmo <= 0 || this._currentAmmo === this._magSize) return;
|
||||
if (this._player.getIsFlying()) return;
|
||||
|
||||
this._isReloading = true;
|
||||
|
||||
// 1. 中断当前所有攻击状态
|
||||
this._isTriggerDown = false;
|
||||
this._isFiring = false;
|
||||
if (this._weaponShootAction) this._weaponShootAction.stop();
|
||||
if (this._firstShotTimer) { clearTimeout(this._firstShotTimer); this._firstShotTimer = null; }
|
||||
this._cancelHoldAimTimer();
|
||||
this.exitAim(); // 换弹时强制退出瞄准
|
||||
|
||||
// 2. 播放换弹动画(仅上半身,下半身继续播放移动动画)
|
||||
this._player.playUpperBody("upper_reload", { force: true, fade: 0.18 });
|
||||
|
||||
// 换弹音效
|
||||
this._effects?.triggerReloadSound();
|
||||
|
||||
// 显示备用弹夹骨骼
|
||||
if (this._magazineBone) this._magazineBone.scale.setScalar(1);
|
||||
|
||||
// 播放武器自身的换弹动画
|
||||
if (this._weaponReloadAction) {
|
||||
this._weaponReloadAction.reset();
|
||||
this._weaponReloadAction.play();
|
||||
}
|
||||
|
||||
// 动画播放到一半时停止,同时隐藏备用弹夹骨骼(避免 snap-back 到悬空位置时可见)
|
||||
this._reloadTimer1 = setTimeout(() => {
|
||||
this._reloadTimer1 = null;
|
||||
if (this._isReloading && this._weaponReloadAction) {
|
||||
this._weaponReloadAction.stop();
|
||||
}
|
||||
if (this._magazineBone) this._magazineBone.scale.setScalar(0.0001);
|
||||
}, this._RELOAD_DURATION_MS / 2);
|
||||
|
||||
// 3. 锁定状态直到动画结束
|
||||
this._reloadTimer2 = setTimeout(() => {
|
||||
this._reloadTimer2 = null;
|
||||
// 此时 _isReloading 变回 false,允许开火
|
||||
this._isReloading = false;
|
||||
|
||||
// 停止上半身换弹动画,让全身 locomotion 动画重新完整接管上半身
|
||||
this._player.stopUpperBody(0.18);
|
||||
|
||||
// 确保动画彻底停止(以防上面的一半时间停止没触发成功)
|
||||
if (this._weaponReloadAction) this._weaponReloadAction.stop();
|
||||
|
||||
// 计算需要补充的子弹数量
|
||||
const need = this._magSize - this._currentAmmo;
|
||||
const transfer = Math.min(need, this._totalAmmo);
|
||||
this._currentAmmo += transfer;
|
||||
this._totalAmmo -= transfer;
|
||||
this._hud.updateAmmo?.(this._currentAmmo, this._totalAmmo);
|
||||
|
||||
// 换弹结束后,衔接回持枪状态
|
||||
if (this._currentMode === MODE.PRIMARY) {
|
||||
this._player.switchLocomotionSet("primary_aim");
|
||||
if (this._isAiming || this._isSoftAiming || !this._player.isMoving) {
|
||||
this._player.playUpperBody("upper_aim", { fade: 0.18 });
|
||||
this._scheduleHoldAim();
|
||||
} else {
|
||||
this._player.stopUpperBody(0.18);
|
||||
}
|
||||
}
|
||||
}, this._RELOAD_DURATION_MS);
|
||||
}
|
||||
|
||||
// ==================== 开火状态机 ====================
|
||||
|
||||
_startFiring() {
|
||||
if (!this._canRunCombatLogic()) return;
|
||||
if (this._firstShotTimer || this._isFiring) return;
|
||||
|
||||
// 没子弹时按左键,直接触发换弹
|
||||
if (this._currentAmmo <= 0) {
|
||||
if (this._totalAmmo > 0) this.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
// 切换到软瞄准状态
|
||||
if (!this._isAiming && !this._isSoftAiming) {
|
||||
this.enterAim(true);
|
||||
}
|
||||
|
||||
this._cancelHoldAimTimer();
|
||||
this._isTriggerDown = true;
|
||||
|
||||
// 无论鼠标是否松开,180ms 后必定打出第一枪
|
||||
this._firstShotTimer = setTimeout(() => {
|
||||
this._firstShotTimer = null;
|
||||
if (!this._canRunCombatLogic()) {
|
||||
this._forceStopCombatLogic();
|
||||
return;
|
||||
}
|
||||
this._fireOnce();
|
||||
this._lastFireTime = this._elapsed;
|
||||
|
||||
if (this._isTriggerDown) {
|
||||
this._isFiring = true; // 持续按住 → 连射
|
||||
} else {
|
||||
// 单击松开:打出第一枪后处理后续状态
|
||||
if (this._weaponShootAction) this._weaponShootAction.stop();
|
||||
if (this._isAiming || this._isSoftAiming || !this._player.isMoving) {
|
||||
this._scheduleHoldAim();
|
||||
} else {
|
||||
this._player.stopUpperBody(0.18);
|
||||
}
|
||||
}
|
||||
}, this._FIRE_ANIM_FADE_MS);
|
||||
}
|
||||
|
||||
_stopFiring() {
|
||||
this._isTriggerDown = false;
|
||||
if (this._weaponShootAction) this._weaponShootAction.stop();
|
||||
|
||||
if (!this._isFiring) {
|
||||
// 换弹中或 hold-aim 缓冲期内松开鼠标,不打断上半身动画
|
||||
if (this._isReloading || this._holdAimTimer) return;
|
||||
|
||||
// 单发(鼠标在 180ms 定时器触发前就松开):恢复或停止上半身
|
||||
if (this._isAiming || this._isSoftAiming) {
|
||||
this._player.playUpperBody("upper_aim", { fade: 0.18 });
|
||||
} else {
|
||||
this._player.stopUpperBody(0.18);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._isFiring = false;
|
||||
if (this._isAiming || this._isSoftAiming || !this._player.isMoving) {
|
||||
this._player.playUpperBody("upper_aim", { fade: 0.18 });
|
||||
this._scheduleHoldAim();
|
||||
} else {
|
||||
this._player.stopUpperBody(0.18);
|
||||
}
|
||||
}
|
||||
|
||||
_fireOnce() {
|
||||
if (!this._canRunCombatLogic()) return;
|
||||
|
||||
// 播放音效
|
||||
if (this._shotSound && this._shotSound.buffer) {
|
||||
if (this._shotSound.isPlaying) this._shotSound.stop();
|
||||
this._shotSound.play();
|
||||
}
|
||||
|
||||
// 第三人称触发镜头抖动
|
||||
if (!this._player.getIsFirstPerson()) {
|
||||
this._shakeIntensity = 0.01; // 弧度单位,0.02 约为 1.1 度
|
||||
}
|
||||
|
||||
this._currentAmmo--;
|
||||
this._hud.updateAmmo?.(this._currentAmmo, this._totalAmmo);
|
||||
|
||||
// 枪口火焰 + 音效:无论命中什么都必须先触发
|
||||
if (this._effects && this._muzzlePoint) {
|
||||
this._muzzlePoint.updateWorldMatrix(true, false);
|
||||
this._muzzlePoint.getWorldPosition(_muzzleWorldPos);
|
||||
this._effects.triggerMuzzleFlash(_muzzleWorldPos, this._camera);
|
||||
}
|
||||
|
||||
// 多人模式:检测是否命中远程玩家(dmgMult 由命中部位决定:头×2,躯干×1,四肢×0.75)
|
||||
const hitPlayerId = this._frameHit?.object?.userData?.playerId;
|
||||
if (hitPlayerId && this.onHitPlayer) {
|
||||
const dmgMult = this._frameHit.object.userData.dmgMult ?? 1.0;
|
||||
this.onHitPlayer(hitPlayerId, Math.round(30 * dmgMult));
|
||||
this._hud.flashHit();
|
||||
return;
|
||||
}
|
||||
|
||||
const zombieId = this._frameHit?.object?.userData?.zombieId;
|
||||
if (zombieId && this._zombieManager) {
|
||||
this._zombieManager.onHit(zombieId, 30);
|
||||
this._hud.flashHit();
|
||||
} else {
|
||||
this._decalSystem.spawn(this._frameHit, this._effects);
|
||||
}
|
||||
}
|
||||
|
||||
_triggerShootAnim() {
|
||||
if (!this._canRunCombatLogic()) return;
|
||||
// 上半身播放开火动画,下半身继续播放当前移动动画
|
||||
this._player.playUpperBody("upper_shoot", { force: true, fade: 0.18 });
|
||||
if (this._weaponShootAction) this._weaponShootAction.play();
|
||||
}
|
||||
|
||||
// 停火后延迟 2s 自动退出瞄准姿态
|
||||
_scheduleHoldAim() {
|
||||
this._cancelHoldAimTimer();
|
||||
this._holdAimTimer = setTimeout(() => {
|
||||
this._holdAimTimer = null;
|
||||
if (!this._isAiming) {
|
||||
this.exitAim(true);
|
||||
}
|
||||
}, this._HOLD_AIM_DURATION);
|
||||
}
|
||||
|
||||
_cancelReload() {
|
||||
if (!this._isReloading) return;
|
||||
this._isReloading = false;
|
||||
if (this._reloadTimer1) { clearTimeout(this._reloadTimer1); this._reloadTimer1 = null; }
|
||||
if (this._reloadTimer2) { clearTimeout(this._reloadTimer2); this._reloadTimer2 = null; }
|
||||
if (this._weaponReloadAction) this._weaponReloadAction.stop();
|
||||
if (this._magazineBone) this._magazineBone.scale.setScalar(0.0001);
|
||||
this._effects?.stopReloadSound();
|
||||
this._player.stopUpperBody(0.18);
|
||||
this._hud.hideCrosshair();
|
||||
}
|
||||
|
||||
_cancelHoldAimTimer() {
|
||||
if (this._holdAimTimer) { clearTimeout(this._holdAimTimer); this._holdAimTimer = null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
AdditiveBlending,
|
||||
Audio,
|
||||
AudioLoader,
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
NormalBlending,
|
||||
PlaneGeometry,
|
||||
Points,
|
||||
PointsMaterial,
|
||||
Quaternion,
|
||||
TextureLoader,
|
||||
Vector3,
|
||||
} from "three";
|
||||
|
||||
// 复用的临时量(避免每帧 GC)
|
||||
const _rollQ = new Quaternion();
|
||||
const _rollAxis = new Vector3(0, 0, 1);
|
||||
const _camWorldQ = new Quaternion();
|
||||
|
||||
const flashDuration = 0.06; // 枪口火焰持续时间(秒)
|
||||
const smokeCount = 14; // 每次命中的粒子数
|
||||
const smokeLifetime = 0.7; // 硝烟存活时间(秒)
|
||||
const smokePoolSize = 8; // 硝烟对象池大小
|
||||
|
||||
// ==================== 枪口火焰 ====================
|
||||
|
||||
class MuzzleFlash {
|
||||
constructor(scene, texture, scale = 0.01) {
|
||||
this._mesh = new Mesh(
|
||||
new PlaneGeometry(1, 1),
|
||||
new MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
blending: AdditiveBlending,
|
||||
depthWrite: false,
|
||||
opacity: 0,
|
||||
})
|
||||
);
|
||||
this._mesh.visible = false;
|
||||
this._timer = 0;
|
||||
this._scale = scale;
|
||||
this._cam = null;
|
||||
scene.add(this._mesh);
|
||||
}
|
||||
|
||||
// 在枪口位置触发火焰
|
||||
trigger(worldPos, camera) {
|
||||
this._mesh.position.copy(worldPos);
|
||||
this._mesh.scale.setScalar(this._scale);
|
||||
camera.getWorldQuaternion(_camWorldQ);
|
||||
this._mesh.quaternion.copy(_camWorldQ);
|
||||
_rollQ.setFromAxisAngle(_rollAxis, Math.random() * Math.PI * 2); // 随机滚转,避免重复感
|
||||
this._mesh.quaternion.multiply(_rollQ);
|
||||
this._mesh.material.opacity = 1;
|
||||
this._mesh.visible = true;
|
||||
this._timer = flashDuration;
|
||||
this._cam = camera;
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
if (!this._mesh.visible) return;
|
||||
this._timer -= dt;
|
||||
if (this._timer <= 0) {
|
||||
this._mesh.visible = false;
|
||||
return;
|
||||
}
|
||||
// billboard:每帧对齐相机朝向
|
||||
if (this._cam) { this._cam.getWorldQuaternion(_camWorldQ); this._mesh.quaternion.copy(_camWorldQ); }
|
||||
this._mesh.material.opacity = this._timer / flashDuration; // 线性淡出
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 命中硝烟 ====================
|
||||
|
||||
class SmokeEffect {
|
||||
constructor(scene, texture, size = 0.06) {
|
||||
this._active = false;
|
||||
this._timer = 0;
|
||||
this._pos = new Float32Array(smokeCount * 3); // 粒子位置缓冲
|
||||
this._vels = Array.from({ length: smokeCount }, () => new Vector3()); // 粒子速度
|
||||
|
||||
const geo = new BufferGeometry();
|
||||
geo.setAttribute("position", new BufferAttribute(this._pos, 3));
|
||||
|
||||
this._points = new Points(geo, new PointsMaterial({
|
||||
map: texture,
|
||||
size: size,
|
||||
sizeAttenuation: true,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false,
|
||||
blending: NormalBlending,
|
||||
}));
|
||||
this._points.visible = false;
|
||||
scene.add(this._points);
|
||||
}
|
||||
|
||||
// 在命中点触发硝烟,沿法线方向散射
|
||||
trigger(worldPos, worldNormal) {
|
||||
this._active = true;
|
||||
this._timer = smokeLifetime;
|
||||
this._points.visible = true;
|
||||
|
||||
for (let i = 0; i < smokeCount; i++) {
|
||||
const i3 = i * 3;
|
||||
this._pos[i3] = worldPos.x + (Math.random() - 0.5) * 0.02;
|
||||
this._pos[i3 + 1] = worldPos.y + (Math.random() - 0.5) * 0.02;
|
||||
this._pos[i3 + 2] = worldPos.z + (Math.random() - 0.5) * 0.02;
|
||||
this._vels[i]
|
||||
.set(
|
||||
(Math.random() - 0.5) * 0.5,
|
||||
0.15 + Math.random() * 0.25,
|
||||
(Math.random() - 0.5) * 0.5
|
||||
)
|
||||
.addScaledVector(worldNormal, 0.3 + Math.random() * 0.4);
|
||||
}
|
||||
this._points.geometry.attributes.position.needsUpdate = true;
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
if (!this._active) return;
|
||||
this._timer -= dt;
|
||||
if (this._timer <= 0) {
|
||||
this._active = false;
|
||||
this._points.visible = false;
|
||||
return;
|
||||
}
|
||||
this._points.material.opacity = (this._timer / smokeLifetime) * 0.7;
|
||||
for (let i = 0; i < smokeCount; i++) {
|
||||
const i3 = i * 3;
|
||||
this._pos[i3] += this._vels[i].x * dt;
|
||||
this._pos[i3 + 1] += this._vels[i].y * dt;
|
||||
this._pos[i3 + 2] += this._vels[i].z * dt;
|
||||
this._vels[i].multiplyScalar(0.92); // 阻力衰减
|
||||
}
|
||||
this._points.geometry.attributes.position.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 对外接口 ====================
|
||||
|
||||
export class ShootingEffects {
|
||||
constructor(scene, { listener = null, flashScale = 0.01, smokeSize = 0.06 } = {}) {
|
||||
// ==================== 场景引用 ====================
|
||||
this._scene = scene;
|
||||
this._listener = listener; // THREE.AudioListener
|
||||
|
||||
// ==================== 特效配置 ====================
|
||||
this._flashScale = flashScale; // 枪口火焰缩放
|
||||
this._smokeSize = smokeSize; // 硝烟粒子大小
|
||||
|
||||
// ==================== 特效对象 ====================
|
||||
this._flash = null; // MuzzleFlash 实例
|
||||
this._smokePool = []; // SmokeEffect 对象池
|
||||
this._smokeIdx = 0; // 轮转索引
|
||||
this._fireSound = null; // 开火音效
|
||||
this._reloadSound = null; // 换弹音效
|
||||
}
|
||||
|
||||
// 加载贴图与音效,创建特效实例
|
||||
async load(flashTexPath, smokeTexPath, fireSoundPath = null, reloadSoundPath = null) {
|
||||
const loader = new TextureLoader();
|
||||
const audioLoader = new AudioLoader();
|
||||
|
||||
const [flashTex, smokeTex, fireBuffer, reloadBuffer] = await Promise.all([
|
||||
loader.loadAsync(flashTexPath),
|
||||
loader.loadAsync(smokeTexPath),
|
||||
fireSoundPath ? audioLoader.loadAsync(fireSoundPath).catch(() => null) : Promise.resolve(null),
|
||||
reloadSoundPath ? audioLoader.loadAsync(reloadSoundPath).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
this._flash = new MuzzleFlash(this._scene, flashTex, this._flashScale);
|
||||
|
||||
if (fireBuffer && this._listener) {
|
||||
this._fireSound = new Audio(this._listener);
|
||||
this._fireSound.setBuffer(fireBuffer);
|
||||
this._fireSound.setVolume(0.4);
|
||||
}
|
||||
|
||||
if (reloadBuffer && this._listener) {
|
||||
this._reloadSound = new Audio(this._listener);
|
||||
this._reloadSound.setBuffer(reloadBuffer);
|
||||
this._reloadSound.setVolume(0.6);
|
||||
}
|
||||
|
||||
// 预创建硝烟对象池
|
||||
for (let i = 0; i < smokePoolSize; i++) {
|
||||
this._smokePool.push(new SmokeEffect(this._scene, smokeTex, this._smokeSize));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 触发接口 ====================
|
||||
|
||||
// 枪口火焰 + 开火音效
|
||||
triggerMuzzleFlash(worldPos, camera) {
|
||||
this._flash?.trigger(worldPos, camera);
|
||||
if (this._fireSound) {
|
||||
if (this._fireSound.isPlaying) this._fireSound.stop();
|
||||
this._fireSound.play();
|
||||
}
|
||||
}
|
||||
|
||||
// 换弹音效
|
||||
triggerReloadSound() {
|
||||
if (!this._reloadSound) return;
|
||||
if (this._reloadSound.isPlaying) this._reloadSound.stop();
|
||||
this._reloadSound.play();
|
||||
}
|
||||
|
||||
stopReloadSound() {
|
||||
if (this._reloadSound?.isPlaying) this._reloadSound.stop();
|
||||
}
|
||||
|
||||
// 命中硝烟(轮转对象池)
|
||||
triggerHitSmoke(worldPos, worldNormal) {
|
||||
const e = this._smokePool[this._smokeIdx % this._smokePool.length];
|
||||
this._smokeIdx++;
|
||||
e.trigger(worldPos, worldNormal);
|
||||
}
|
||||
|
||||
// 每帧更新所有特效
|
||||
update(dt) {
|
||||
this._flash?.update(dt);
|
||||
for (const e of this._smokePool) e.update(dt);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user