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,124 @@
import {
ACESFilmicToneMapping,
DirectionalLight,
MathUtils,
PerspectiveCamera,
Scene,
VSMShadowMap,
WebGLRenderer,
} from "three";
import { MapControls } from "three/examples/jsm/Addons.js";
import { LightProbeGrid } from "three/addons/lighting/LightProbeGrid.js";
import { LightProbeGridHelper } from "three/addons/helpers/LightProbeGridHelper.js";
export function createSceneSetup({ container }) {
// ==================== 渲染器 ====================
const renderer = new WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.toneMapping = ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = VSMShadowMap;
container.appendChild(renderer.domElement);
// ==================== 场景 & 相机 ====================
const scene = new Scene();
const camera = new PerspectiveCamera(
60,
container.clientWidth / container.clientHeight,
0.1,
2000
);
// ==================== 控制器 ====================
const controls = new MapControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.1;
controls.maxDistance = 2000;
// ==================== 参数 ====================
const params = {
showLightHelper: true,
lightAzimuth: 45,
lightElevation: 45,
lightIntensity: 10,
shadows: true,
probesEnabled: true,
showProbes: false,
probeSize: 0.5,
boundsX: 8, boundsY: 3.2, boundsZ: -2,
sizeX: 40, sizeY: 3, sizeZ: 25,
countX: 20, countY: 4, countZ: 10,
};
// ==================== 平行光 ====================
const dirLight = new DirectionalLight(0xfff2dc, params.lightIntensity);
dirLight.castShadow = true;
dirLight.shadow.mapSize.setScalar(2048);
dirLight.shadow.camera.left = -50;
dirLight.shadow.camera.right = 50;
dirLight.shadow.camera.top = 50;
dirLight.shadow.camera.bottom = -50;
dirLight.shadow.camera.near = 0.1;
dirLight.shadow.camera.far = 200;
dirLight.shadow.bias = -0.001;
scene.add(dirLight);
scene.add(dirLight.target);
function updateLightPosition() {
const azimuth = MathUtils.degToRad(params.lightAzimuth);
const elevation = MathUtils.degToRad(params.lightElevation);
const radius = 100;
const h = Math.cos(elevation) * radius;
const v = Math.sin(elevation) * radius;
dirLight.position.set(Math.cos(azimuth) * h, v, Math.sin(azimuth) * h);
dirLight.target.position.set(0, 0, 0);
dirLight.target.updateMatrixWorld();
}
updateLightPosition();
// ==================== 光探针 ====================
let probes = null;
let probesHelper = null;
let isBaking = false;
let bakeQueued = false;
async function bakeProbes() {
if (isBaking) { bakeQueued = true; return; }
isBaking = true;
do {
bakeQueued = false;
if (probes) { scene.remove(probes); probes.dispose(); }
probes = new LightProbeGrid(
params.sizeX, params.sizeY, params.sizeZ,
params.countX, params.countY, params.countZ
);
probes.position.set(params.boundsX, params.boundsY, params.boundsZ);
probes.updateMatrixWorld(true); // 强制更新世界矩阵,否则 bake() 读到旧位置
if (probesHelper) probesHelper.visible = false;
const far = Math.max(params.sizeX, params.sizeY, params.sizeZ) * 2;
probes.bake(renderer, scene, { cubemapSize: 32, near: 0.05, far });
probes.visible = params.probesEnabled;
scene.add(probes);
if (!probesHelper) {
probesHelper = new LightProbeGridHelper(probes, params.probeSize);
scene.add(probesHelper);
} else {
probesHelper.probes = probes;
probesHelper.update();
}
probesHelper.position.set(params.boundsX, params.boundsY, params.boundsZ);
probesHelper.visible = params.showProbes;
} while (bakeQueued);
isBaking = false;
}
return { scene, renderer, camera, controls, dirLight, bakeProbes };
}
@@ -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);
}
}
}
@@ -0,0 +1,214 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>three-player-controller · FPS Game</title>
<link rel="stylesheet" href="../styles/common.css" />
<style>
html, body {
height: 100%;
margin: 0;
}
#container {
overflow: hidden !important;
width: 100vw;
height: 100vh;
position: absolute;
background: transparent;
}
/* 准心 */
#crosshair {
display: none;
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 6px;
height: 6px;
background: rgba(255, 255, 255, 0.9);
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
pointer-events: none;
z-index: 9999;
}
/* 弹药面板位于右下角源码按钮上方,间距 8px。 */
#weapon-hud { display: none; }
#br-panel {
position: fixed; bottom: 72px; right: 16px; z-index: 9999;
display: flex; flex-direction: column; align-items: center; gap: 8px;
}
#ammo-hud {
display: none; flex-direction: column; align-items: center; gap: 4px;
}
.weapon-img-box {
width: 96px; height: 44px;
display: flex; align-items: center; justify-content: center;
}
.ammo-row {
display: flex; align-items: center; gap: 4px;
padding: 3px 16px 3px 8px; border-radius: 4px;
background: linear-gradient(to right, rgba(0,0,0,0.7), transparent);
}
.ammo-label-tag {
font-family: system-ui; font-size: 9px; font-weight: bold;
letter-spacing: 0.18em; color: #fff;
text-shadow: 0 1px 4px rgba(0,0,0,0.9);
}
#ammo-current-val {
color: #e8a020; font-family: monospace; font-size: 22px;
font-weight: bold; line-height: 1;
text-shadow: 0 1px 4px rgba(0,0,0,0.9);
}
.ammo-divider { color: #fff; font-family: monospace; font-size: 15px; margin: 0 1px; }
#ammo-total-val { color: #fff; font-family: monospace; font-size: 13px;
text-shadow: 0 1px 4px rgba(0,0,0,0.9); }
/* ── Start Screen ── */
#start-screen {
position: fixed;
inset: 0;
z-index: 99998;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(6px);
}
#start-btn {
appearance: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
padding: 16px 48px;
background: #c0392b;
color: #fff;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
border-radius: 4px;
box-shadow: 0 4px 24px rgba(192,57,43,0.55);
transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
}
#start-btn:hover {
background: #e74c3c;
transform: scale(1.04);
box-shadow: 0 6px 32px rgba(192,57,43,0.7), 0 0 0 4px rgba(231,76,60,0.25);
}
#start-btn:active { transform: scale(0.98); }
#start-btn svg {
width: 18px;
height: 18px;
fill: currentColor;
flex-shrink: 0;
}
</style>
<script src="../loader.js" data-demo="FPS Game"></script>
</head>
<body>
<div id="container"></div>
<div id="crosshair"></div>
<div id="start-screen">
<button id="start-btn">
<svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9"/></svg>
Play
</button>
</div>
<div class="hud" role="note" aria-label="Controls">
<div class="hint-move">
<div class="hint-cluster">
<div class="wasd">
<kbd class="wasd-w">W</kbd>
<kbd class="wasd-a">A</kbd>
<kbd class="wasd-s">S</kbd>
<kbd class="wasd-d">D</kbd>
</div>
<span class="hint-text">Move</span>
</div>
<div class="hint-cluster">
<div class="hint-mouse" aria-hidden="true">
<div class="hint-mouse-body">
<span class="hint-mouse-wheel"></span>
<span class="hint-mouse-nub"></span>
</div>
</div>
<span class="hint-text">Look</span>
</div>
</div>
<div class="hint-actions">
<div class="hint-group">
<span class="hint-keys"><kbd>Shift</kbd></span>
<span class="hint-text">Sprint</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>Space</kbd></span>
<span class="hint-text">Jump</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>V</kbd></span>
<span class="hint-text">Toggle View</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>1</kbd><kbd>4</kbd></span>
<span class="hint-text">Switch Weapon / Scroll</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>RMB</kbd></span>
<span class="hint-text">Aim (Hold)</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>LMB</kbd></span>
<span class="hint-text">Shoot</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>R</kbd></span>
<span class="hint-text">Reload</span>
</div>
</div>
</div>
<div id="weapon-hud"></div>
<a class="source" href="https://github.com/hh-hang/three-player-controller/blob/master/example/shooting/shooting.js" target="_blank" rel="noopener noreferrer">
<img src="../img/source.svg" alt="" />
</a>
<div id="br-panel">
<div id="ammo-hud">
<div class="weapon-img-box">
<svg width="96" height="38" viewBox="0 0 96 38" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 18 L2 24 L20 24 L22 20 L20 15 L2 15 Z" fill="rgba(210,195,160,0.8)"/>
<rect x="18" y="13" width="50" height="12" rx="2" fill="rgba(210,195,160,0.8)"/>
<path d="M36 25 L34 37 L44 37 L46 25 Z" fill="rgba(190,175,140,0.85)"/>
<rect x="66" y="15" width="28" height="6" rx="1" fill="rgba(210,195,160,0.8)"/>
<rect x="88" y="12" width="2" height="3" fill="rgba(210,195,160,0.7)"/>
<rect x="30" y="9" width="22" height="5" rx="1" fill="rgba(190,175,140,0.7)"/>
</svg>
</div>
<div class="ammo-row">
<span class="ammo-label-tag">AMMO</span>
<span id="ammo-current-val">30</span>
<span class="ammo-divider">/</span>
<span id="ammo-total-val">300</span>
</div>
</div>
</div>
<div class="model-credit">
Scene model:
<a href="https://sketchfab.com/3d-models/horror-corridor-3-e03a192de67d442ebdf42f43a8a2d37a" target="_blank" rel="noopener noreferrer">Sketchfab</a>
<br />
</div>
<script type="module" src="../control-hints.js"></script>
<script type="module" src="./shooting.js"></script>
</body>
</html>
@@ -0,0 +1,294 @@
import * as THREE from "three";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
import Stats from "three/examples/jsm/libs/stats.module.js";
import { createSceneSetup } from "./core/SceneSetup.js";
import { LocalPlayer } from "./player/LocalPlayer.js";
import { DecalSystem } from "./weapon/DecalSystem.js";
import { WeaponController, MODE } from "./weapon/WeaponController.js";
import { ShootingEffects } from "./weapon/effects.js";
import { HUD } from "./ui/HUD.js";
import { ZombieManager } from "./zombie/ZombieManager.js";
const base = import.meta.env.BASE_URL;
const initPos = new THREE.Vector3(33.21, 4.6, 2.6);
const NORMAL_MAX_CAM = 220;
const MOUSE_SENSITIVITY = 5;
const PLAYER_MODEL_URL = base + "./glb/swat.glb";
const FIRST_PERSON_PITCH_OFFSET_BY_MODEL = {
[base + "./glb/swat.glb"]: Math.PI * (16 / 180),
};
async function init() {
const container = document.querySelector("#container");
let tiles, localPlayer, weapon, zombieManager;
// ==================== 1. 场景基础 ====================
const { scene, renderer, camera, controls, bakeProbes } = createSceneSetup({ container });
// 添加音频监听器
const listener = new THREE.AudioListener();
camera.add(listener);
camera.position.copy(initPos);
camera.lookAt(initPos.x, initPos.y, initPos.z + 1);
controls.target.set(initPos.x, initPos.y, initPos.z + 1);
const stats = new Stats();
Object.assign(stats.dom.style, {
position: "fixed",
bottom: "0px",
left: "0px",
top: "auto",
zIndex: "9998",
display: "block",
});
document.body.appendChild(stats.dom);
const timer = new THREE.Timer();
let _prevGunEngaged = false;
renderer.setAnimationLoop(() => {
timer.update();
const elapsed = timer.getElapsed();
const dt = timer.getDelta();
if (localPlayer && weapon && zombieManager) {
const spineIK = localPlayer.spineIK;
const gunEngaged = weapon.isGunEngaged();
// 开火/瞄准时切 mouseMode 5(锁定朝向跟相机),其余情况用 1
if (gunEngaged !== _prevGunEngaged) {
localPlayer.setThirdMouseMode(gunEngaged ? 5 : 1);
_prevGunEngaged = gunEngaged;
}
// 恢复脊椎骨骼到上一帧的干净动画姿态
if (gunEngaged) spineIK.restoreBones();
// 驱动动画与物理(传入 dt,使上半身 mixer 与主 mixer 同步)
localPlayer ? localPlayer.update(dt) : controls.update();
// 应用脊椎 IK 与朝向(在 player.update() 后、weapon.update() 前)
if (gunEngaged) {
if (localPlayer.getIsFirstPerson()) {
localPlayer.applyHipsCorrection();
spineIK.applyAim1P(camera, localPlayer.pitchTarget1P);
} else {
// console.log('应用 3P IK');
localPlayer.applyHipsCorrection();
spineIK.applyAim3P(camera, true);
}
}
// 更新武器状态(特效、射线命中、连射节拍)——骨骼此时已含 IK
weapon.update(elapsed, dt);
// 丧尸 AI
zombieManager.update(dt, localPlayer.getPosition());
} else {
controls?.update();
}
renderer.render(scene, camera);
stats.update();
});
// ==================== 2. 资源加载器 ====================
const gltfLoader = new GLTFLoader();
const draco = new DRACOLoader();
draco.setDecoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/draco/");
gltfLoader.setDRACOLoader(draco);
const ktx2 = new KTX2Loader();
ktx2.setTranscoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/basis/");
ktx2.detectSupport(renderer);
gltfLoader.setKTX2Loader(ktx2);
// ==================== 3. 场景模型 ====================
const { scene: mapScene } = await gltfLoader.loadAsync(
base + "./glb/horror_corridor.glb"
);
mapScene.name = "sceneGLB";
mapScene.scale.set(0.09, 0.09, 0.09);
scene.add(mapScene);
scene.updateMatrixWorld(true);
bakeProbes();
document.addEventListener("contextmenu", (e) => e.preventDefault());
const playerGltf = await gltfLoader.loadAsync(PLAYER_MODEL_URL);
// ==================== 4. 本地玩家 ====================
localPlayer = new LocalPlayer({ scene, camera, controls });
await localPlayer.init({
playerModelConfig: {
model: playerGltf.scene,
animations: playerGltf.animations,
scale: 0.01,
idleAnim: "idle1",
walkAnim: "walk",
runAnim: "run",
jumpAnim: "jump",
flyAnim: "flying",
flyIdleAnim: "flyidle",
headBoneName: "mixamorigHead",
firstPersonPitchOffset: FIRST_PERSON_PITCH_OFFSET_BY_MODEL[PLAYER_MODEL_URL] ?? 0,
rotateY: - Math.PI / 2,
},
initPos: initPos,
minCamDistance: 10,
maxCamDistance: NORMAL_MAX_CAM,
enableOverShoulderView: true,
mouseSensitivity: MOUSE_SENSITIVITY,
camLookAtHeightRatio: 0.9,
keyMap: { toggleFly: null },
colliders: [
{ motion: "static", shape: { kind: "mesh", source: mapScene } },
],
});
localPlayer.getPlayerModel()?.traverse((c) => {
if (c.isMesh) {
c.frustumCulled = false;
c.castShadow = true;
c.receiveShadow = true;
}
});
// ==================== 5. HUD ====================
const weaponSlots = [
{ key: "1", mode: "primary", label: "Rifle" },
{ key: "4", mode: "normal", label: "Fists" },
];
const hud = new HUD(weaponSlots);
hud.build();
// ==================== 6. 特效 ====================
let effects = new ShootingEffects(scene, {
listener,
flashScale: 0.15,
smokeSize: 0.8
});
await effects.load(
base + "./img/muzzle_flash.png",
base + "./img/smoke.png",
base + "./audio/gun_shot.mp3",
base + "./audio/reload.mp3",
);
// ==================== 7. 弹痕系统 ====================
const decalSystem = new DecalSystem(scene, 60, 0.25);
await decalSystem.loadMaterials(["img/bullet_hole.png"], base);
// ==================== 8. 丧尸管理器 ====================
zombieManager = new ZombieManager(scene, {
loader: gltfLoader,
colliders: localPlayer.getColliderMeshes(),
modelUrl: base + "./glb/zombie.glb",
scale: 0.01,
walkAnim: "walking",
idleAnim: "Idle",
punchAnim: "punching",
runAnim: "running",
rotateY: Math.PI,
speed: 120,
});
// ==================== 9. 武器控制器 ====================
weapon = new WeaponController({
scene,
camera,
localPlayer,
decalSystem,
effects,
hud,
zombieManager,
});
await weapon.load(gltfLoader, base);
weapon.setupAnimations();
weapon.bindInput();
weapon.switchMode(MODE.PRIMARY);
// 让 LocalPlayer 的 1P 俯仰驱动能感知武器状态
localPlayer.setGunEngagedGetter(() => weapon.isGunEngaged());
// 初始刷新武器槽高亮
hud.update(weapon.getMode());
// ==================== 10. 鼠标拾取 ====================
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const getMousePickingPoint = (event) => {
const rect = container.getBoundingClientRect();
// 将鼠标位置归一化为设备坐标 (NDC)
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
// 检测场景中所有的物体(包含 3D Tiles 和玩家模型)
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
return intersects[0].point;
}
return null;
};
container.addEventListener("mousedown", (e) => {
// 只有当鼠标没有被锁定(非第一人称控制状态)时才执行点击拾取
if (document.pointerLockElement !== renderer.domElement) {
const point = getMousePickingPoint(e);
if (point) {
console.log("鼠标点击的 3D 坐标:", point);
// 你可以在这里添加逻辑,比如在点击位置生成丧尸:
// zombieManager.spawnZombie(point);
}
}
});
// ==================== 丧尸波次 ====================
await Promise.all([
zombieManager.startWave({ origin: new THREE.Vector3(2.012, 4.6, -0.78), count: 5, radius: 1 }),
zombieManager.startWave({ origin: new THREE.Vector3(-3.731, 4.6, -1.824), count: 2, radius: 1 }),
zombieManager.startWave({ origin: new THREE.Vector3(11.911, 4.6, -4.010), count: 2, radius: 1 }),
zombieManager.startWave({ origin: new THREE.Vector3(13.567, 4.6, 7.001), count: 2, radius: 1 }),
]);
window.hideLoader?.();
// ==================== Start Screen ====================
await new Promise((resolve) => {
const screen = document.getElementById("start-screen");
screen.addEventListener("mousedown", (e) => e.stopPropagation());
screen.addEventListener("mouseup", (e) => e.stopPropagation());
screen.addEventListener("click", (e) => e.stopPropagation());
screen.style.display = "flex";
document.getElementById("start-btn").addEventListener("click", () => {
screen.remove();
document.body.style.cursor = "none";
document.body.requestPointerLock?.();
resolve();
}, { once: true });
});
// ==================== 窗口缩放 ====================
window.addEventListener("resize", () => {
const cont = document.querySelector("#container");
if (!cont) return;
camera.aspect = cont.clientWidth / cont.clientHeight;
renderer.setSize(cont.clientWidth, cont.clientHeight);
camera.updateProjectionMatrix();
});
}
init();
@@ -0,0 +1,65 @@
// HUD:准星 + 武器选择栏 + 弹药显示
// 不依赖任何 Three.js 对象,只操作 DOM
export class HUD {
constructor(weaponSlots) {
this._slots = weaponSlots; // [{ key, mode, label }]
this._crosshair = document.getElementById("crosshair");
this._weaponHud = document.getElementById("weapon-hud");
this._ammoHud = document.getElementById("ammo-hud");
this._ammoCurrent = document.getElementById("ammo-current-val");
this._ammoTotal = document.getElementById("ammo-total-val");
this._hitTimer = null;
}
// ==================== 初始化 ====================
build() {
this._weaponHud.innerHTML = "";
for (let i = this._slots.length - 1; i >= 0; i--) {
const slot = this._slots[i];
const el = document.createElement("div");
el.className = "weapon-slot";
el.dataset.mode = slot.mode;
el.innerHTML = `<span class="slot-key">${slot.key}</span><span class="slot-name">${slot.label}</span>`;
this._weaponHud.appendChild(el);
}
}
// ==================== 武器槽 ====================
update(currentMode) {
this._weaponHud.querySelectorAll(".weapon-slot").forEach((el) => {
el.classList.toggle("active", el.dataset.mode === currentMode);
});
}
// ==================== 弹药 ====================
updateAmmo(current, max) {
if (this._ammoCurrent) this._ammoCurrent.textContent = current;
if (this._ammoTotal) this._ammoTotal.textContent = max;
}
showAmmo() {
if (this._ammoHud) this._ammoHud.style.display = "flex";
}
hideAmmo() {
if (this._ammoHud) this._ammoHud.style.display = "none";
}
// ==================== 准星 ====================
showCrosshair() { this._crosshair.style.display = "block"; }
hideCrosshair() { this._crosshair.style.display = "none"; }
flashHit() {
if (!this._crosshair) return;
if (this._hitTimer) clearTimeout(this._hitTimer);
this._crosshair.style.backgroundColor = "red";
this._hitTimer = setTimeout(() => {
this._crosshair.style.backgroundColor = "";
}, 100);
}
}
@@ -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);
}
}
@@ -0,0 +1,440 @@
import * as THREE from "three";
import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js";
export class ZombieEntity {
constructor(scene, id) {
// ==================== 场景引用 ====================
this._scene = scene;
this.id = id; // 唯一标识
// ==================== 场景对象 ====================
this._colliders = []; // 场景碰撞网格(CollisionWorld 查询结果)
this._model = null; // 模型根节点
this._capsule = null; // 碰撞胶囊体
this._capsuleInfo = null; // 胶囊描述(radius + segment)
this._mixer = null; // 动画混合器
// ==================== 动画动作 ====================
this._walkAction = null; // 行走
this._runAction = null; // 奔跑
this._idleAction = null; // 待机
this._punchAction = null; // 攻击
this._deathAction = null; // 死亡
this._currentAction = null; // 当前播放动作
this._punchLoopCb = null; // 攻击循环回调(用于 removeEventListener)
// ==================== 生命状态 ====================
this.hp = 100; // 血量
this.isDead = false; // 是否已死亡
this._deathTime = 0; // 死亡后累计时间(用于定时清除)
this._isRunning = Math.random() > 0.5; // 随机决定是否为跑者
this._state = "idle"; // 当前行为状态
this._isPunching = false; // 是否正在攻击
this._inAttackRange = false; // 是否在攻击范围内
// ==================== 物理参数 ====================
this._gravity = -2.4; // 重力加速度(load 时按 scale 重算)
this._speed = 0.12; // 移速(load 时按 scale 重算)
this._modelScale = 0.001; // 模型缩放
this._capsuleHeight = 180; // 胶囊高度(模型空间,load 时重算)
this._capsuleRadius = 45; // 胶囊半径(模型空间,load 时重算)
this._velocity = new THREE.Vector3(); // 当前速度(主要用于重力累积)
this._onGround = false; // 是否落地
// ==================== 复用向量 ====================
this._tempBox = new THREE.Box3();
this._tempMat = new THREE.Matrix4();
this._tempSeg = new THREE.Line3();
this._tempV1 = new THREE.Vector3();
this._tempV2 = new THREE.Vector3();
this._moveDir = new THREE.Vector3(); // 水平移动方向(单位向量)
this._lookTarget = new THREE.Vector3(); // 朝向目标(lookAt 复用)
this._raycaster = new THREE.Raycaster(
new THREE.Vector3(),
new THREE.Vector3(0, -1, 0),
);
this._raycaster.firstHitOnly = true; // 只取最近地面交点
}
// 加载模型、绑定动画、构建胶囊体,挂入场景
async load(gltfLoader, {
modelUrl,
colliders,
position,
scale = 0.001,
walkAnim,
runAnim,
idleAnim,
punchAnim,
deathAnim,
rotateY = Math.PI,
speed = 120,
}) {
this._colliders = colliders?.length ? colliders.slice() : [];
this._modelScale = scale;
this._gravity = -2400 * scale;
// 基础移速增加随机扰动 (±15%),使丧尸步频与步幅产生差异
this._speed = speed * scale * (0.85 + Math.random() * 0.3);
const gltf = await gltfLoader.loadAsync(modelUrl);
this._model = gltf.scene;
this._mixer = new THREE.AnimationMixer(this._model);
const clips = gltf.animations ?? [];
// 按名称或模糊匹配查找动画片段
const findClip = (hint) => {
if (!hint) return null;
return (
clips.find((clip) => clip.name === hint) ??
clips.find((clip) => clip.name.toLowerCase().includes(hint.toLowerCase())) ??
null
);
};
const walkClip = findClip(walkAnim) ?? findClip("walk") ?? clips[0] ?? null;
const runClip = findClip(runAnim) ?? findClip("run") ?? clips[0] ?? null;
const idleClip = findClip(idleAnim) ?? findClip("idle") ?? clips[0] ?? null;
const punchClip = findClip(punchAnim) ?? findClip("punch") ?? null;
const deathClip = findClip(deathAnim) ?? findClip("dying") ?? findClip("death") ?? findClip("die") ?? null;
// 创建循环动作并初始权重为 0(由 _playAnim 按需激活)
const makeAction = (clip) => {
if (!clip) return null;
const action = this._mixer.clipAction(clip);
action.setLoop(THREE.LoopRepeat, Infinity);
action.setEffectiveWeight(0);
action.enabled = true;
return action;
};
this._walkAction = makeAction(walkClip);
if (this._walkAction) {
// 随机播放速度 (±10%) 和 随机起始进度 (0-100%)
this._walkAction.setEffectiveTimeScale(1.5 * (0.9 + Math.random() * 0.2));
this._walkAction.time = Math.random() * walkClip.duration;
}
this._runAction = makeAction(runClip);
if (this._runAction) {
this._runAction.setEffectiveTimeScale(1.2 * (0.9 + Math.random() * 0.2));
this._runAction.time = Math.random() * runClip.duration;
}
this._idleAction = idleClip !== walkClip ? makeAction(idleClip) : this._walkAction;
if (this._idleAction) {
// 即使是待机动作也打乱起始时间
this._idleAction.time = Math.random() * (idleClip?.duration ?? 1);
}
if (punchClip) {
this._punchAction = this._mixer.clipAction(punchClip);
this._punchAction.setLoop(THREE.LoopRepeat, Infinity);
this._punchAction.setEffectiveTimeScale(1.5);
this._punchAction.setEffectiveWeight(0);
this._punchAction.enabled = true;
// 每次攻击循环结束时检查是否已离开攻击范围,若已离开则切回移动动画
this._punchLoopCb = (event) => {
if (event.action !== this._punchAction || !this._isPunching) return;
if (!this._inAttackRange) {
this._isPunching = false;
this._playAnim(this._walkAction ?? this._idleAction);
}
};
this._mixer.addEventListener("loop", this._punchLoopCb);
}
this._deathAction = makeAction(deathClip);
if (this._deathAction) {
this._deathAction.setLoop(THREE.LoopOnce, 1);
this._deathAction.clampWhenFinished = true; // 停在最后一帧
}
// 驱动一帧使骨骼到位,再计算包围盒以得到准确尺寸
this._mixer.update(0);
this._model.updateMatrixWorld(true);
const bbox = new THREE.Box3().setFromObject(this._model);
const size = new THREE.Vector3();
bbox.getSize(size);
// 将模型归一化到参考高度 180,再乘以 scale 得到世界尺寸
const refHeight = 180;
const modelScaleFactor = refHeight / size.y;
this._capsuleHeight = size.y * modelScaleFactor;
this._capsuleRadius = Math.min(size.x, size.z) * modelScaleFactor;
const radius = this._capsuleRadius * scale;
const height = this._capsuleHeight * scale;
// 胶囊体:透明网格,仅用于碰撞,不渲染
this._capsule = new THREE.Mesh(
new RoundedBoxGeometry(radius * 2, height, radius * 2, 1, 0.75),
new THREE.MeshStandardMaterial({
transparent: true,
opacity: 0,
depthWrite: false,
}),
);
this._capsule.geometry.translate(0, -height * 0.25, 0);
this._capsuleInfo = {
radius,
segment: new THREE.Line3(
new THREE.Vector3(),
new THREE.Vector3(0, -height * 0.5, 0),
),
};
this._capsule.name = "zombie_capsule";
this._capsule.userData.zombieId = this.id;
this._capsule.layers.enable(2); // layer 2:可被武器射线检测到
this._scene.add(this._capsule);
this._capsule.position.copy(position);
this._model.scale.setScalar(modelScaleFactor * scale);
this._model.position.set(0, -height * 0.75, 0);
this._model.rotation.y = rotateY;
this._model.traverse((child) => {
child.userData.zombieId = this.id;
child.castShadow = true;
child.receiveShadow = true;
});
this._capsule.add(this._model);
this._playAnim(this._idleAction ?? this._walkAction);
}
// ==================== 主循环 ====================
update(delta, playerPos) {
if (!this._capsule || !this._colliders.length) return;
delta = Math.min(delta, 1 / 40); // 防止帧间隔过大导致穿透
if (this.isDead) {
this._deathTime += delta;
this._mixer?.update(delta);
return;
}
// 重力累积
if (!this._onGround) {
this._velocity.y += delta * this._gravity;
}
this._capsule.position.addScaledVector(this._velocity, delta);
// 计算与玩家的水平距离,判断是否进入攻击范围
const pos = this._capsule.position;
const dx = playerPos.x - pos.x;
const dz = playerPos.z - pos.z;
const horizDist = Math.sqrt(dx * dx + dz * dz);
const stopRadius = this._capsuleInfo.radius * 2;
this._inAttackRange = horizDist <= stopRadius;
// 朝向玩家(lookAt 反向,使模型正面对着玩家)
this._lookTarget.set(
2 * pos.x - playerPos.x,
pos.y,
2 * pos.z - playerPos.z,
);
this._capsule.lookAt(this._lookTarget);
if (this._inAttackRange) {
this._moveDir.set(0, 0, 0);
if (!this._isPunching) {
this._isPunching = true;
this._state = "attack";
this._playAnim(this._punchAction ?? this._idleAction ?? this._walkAction);
}
} else {
// 只要不在攻击范围内,立即重置攻击标记以允许移动逻辑执行
this._isPunching = false;
if (horizDist > 1e-5) {
this._moveDir.set(dx / horizDist, 0, dz / horizDist);
this._state = this._isRunning ? "run" : "walk";
const moveAnim = this._isRunning ? (this._runAction ?? this._walkAction) : this._walkAction;
this._playAnim(moveAnim ?? this._idleAction);
} else {
this._moveDir.set(0, 0, 0);
this._state = "idle";
this._playAnim(this._idleAction ?? this._walkAction);
}
}
const currentSpeed = this._isRunning ? this._speed * 1.5 : this._speed;
this._applyEnvironmentCollision(delta, currentSpeed);
this._applyGrounding(delta);
this._mixer?.update(delta);
}
// ==================== 伤害 / 死亡 ====================
takeDamage(damage) {
if (this.isDead) return false;
this.hp -= damage;
if (this.hp <= 0) {
this.die();
return true; // 本次命中导致死亡
}
return false;
}
die() {
if (this.isDead) return;
this.isDead = true;
this._state = "die";
this._isPunching = false;
this._deathTime = 0;
this._velocity.set(0, 0, 0);
if (this._deathAction) {
this._playAnim(this._deathAction, 0.1);
}
// 禁用图层以便射线检测和自动瞄准忽略尸体
this._capsule.layers.disable(2);
this._model.traverse((child) => {
if (child.isMesh) {
child.layers.disable(2);
}
});
}
// ==================== Getter ====================
getPosition() { return this._capsule?.position ?? null; }
getCapsule() { return this._capsule; }
getCapsuleInfo() { return this._capsuleInfo; }
getDeathTime() { return this._deathTime; }
// ==================== 私有方法 ====================
// 切换动画,带淡入淡出
_playAnim(action, fade = 0.2) {
if (!action || this._currentAction === action) return;
const prev = this._currentAction;
action.reset().setEffectiveWeight(1).play();
if (prev) {
prev.fadeOut(fade);
action.fadeIn(fade);
} else {
action.fadeIn(fade);
}
this._currentAction = action;
}
// 分步移动 + BVH 环境碰撞(只处理竖直面,水平面由 _applyGrounding 负责)
_applyEnvironmentCollision(delta, speed) {
const ci = this._capsuleInfo;
const totalDist = speed * delta;
const maxStep = ci.radius * 0.8; // 每步最大移动量,防止高速穿透
const steps = Math.ceil(totalDist / maxStep) || 1;
const stepDist = totalDist / steps;
for (let i = 0; i < steps; i++) {
this._capsule.position.addScaledVector(this._moveDir, stepDist);
this._capsule.updateMatrixWorld();
for (const collider of this._colliders) {
this._tempBox.makeEmpty();
this._tempMat.copy(collider.matrixWorld).invert();
this._tempSeg.copy(ci.segment);
this._tempSeg.start
.applyMatrix4(this._capsule.matrixWorld)
.applyMatrix4(this._tempMat);
this._tempSeg.end
.applyMatrix4(this._capsule.matrixWorld)
.applyMatrix4(this._tempMat);
this._tempBox.expandByPoint(this._tempSeg.start).expandByPoint(this._tempSeg.end);
this._tempBox.expandByScalar(ci.radius);
collider.geometry?.boundsTree?.shapecast({
intersectsBounds: (box) => box.intersectsBox(this._tempBox),
intersectsTriangle: (tri) => {
const dist = tri.closestPointToSegment(
this._tempSeg,
this._tempV1,
this._tempV2,
);
if (dist >= ci.radius) return;
const normal = tri.getNormal(new THREE.Vector3());
if (Math.abs(normal.y) > 0.5) return; // 水平面交给 _applyGrounding 处理
const dir = this._tempV2.sub(this._tempV1).normalize();
const depth = ci.radius - dist;
this._tempSeg.start.addScaledVector(dir, depth);
this._tempSeg.end.addScaledVector(dir, depth);
},
});
const newPos = this._tempV1
.copy(this._tempSeg.start)
.applyMatrix4(collider.matrixWorld);
const deltaVec = this._tempV2.subVectors(newPos, this._capsule.position);
const offset = Math.max(0, deltaVec.length() - 1e-5);
if (offset > 0) {
this._capsule.position.add(deltaVec.normalize().multiplyScalar(offset));
this._capsule.updateMatrixWorld();
}
}
}
}
// 射线向下检测地面,吸附胶囊体到地面高度
_applyGrounding(delta) {
this._raycaster.ray.origin.copy(this._capsule.position);
const hits = this._raycaster.intersectObjects(this._colliders, false);
if (!hits.length) {
this._onGround = false;
return;
}
const groundY = hits[0].point.y;
const scale = this._modelScale;
const snapHeight = this._capsuleHeight * scale * 0.75; // 胶囊悬停高度
const maxHeight = this._capsuleHeight * scale * 0.9; // 超过此距离视为腾空
const dist = this._capsule.position.y - groundY;
if (dist >= maxHeight) {
this._onGround = false;
return;
}
this._velocity.set(0, 0, 0);
this._onGround = true;
if (dist >= snapHeight) {
// 平滑吸附(走上斜坡时避免抖动)
this._capsule.position.y = THREE.MathUtils.lerp(
this._capsule.position.y,
groundY + snapHeight,
Math.min(1, 15 * delta),
);
} else {
this._capsule.position.y = groundY + snapHeight;
}
}
// ==================== 销毁 ====================
destroy() {
if (this._mixer) {
if (this._punchLoopCb) {
this._mixer.removeEventListener("loop", this._punchLoopCb);
this._punchLoopCb = null;
}
this._mixer.stopAllAction();
this._mixer.uncacheRoot(this._model);
this._mixer = null;
}
if (this._capsule) {
this._scene.remove(this._capsule);
this._capsule.geometry?.dispose();
this._capsule.material?.dispose?.();
this._capsule = null;
}
this._model = null;
this._colliders = [];
}
}
@@ -0,0 +1,150 @@
import { Vector3 } from "three";
import { ZombieEntity } from "./ZombieEntity.js";
export class ZombieManager {
constructor(scene, options = {}) {
// ==================== 场景引用 ====================
this._scene = scene;
// ==================== 丧尸配置 ====================
this._loader = options.loader ?? null; // GLTF 加载器
this._colliders = options.colliders ?? []; // 场景碰撞网格
this._modelUrl = options.modelUrl ?? ""; // 模型路径
this._scale = options.scale ?? 0.01; // 模型缩放
this._rotateY = options.rotateY ?? Math.PI; // 模型初始朝向
this._speed = options.speed ?? 120; // 移速基准值
this._walkAnim = options.walkAnim ?? "walking"; // 行走动画名
this._runAnim = options.runAnim ?? "running"; // 奔跑动画名
this._idleAnim = options.idleAnim ?? "Idle"; // 待机动画名
this._punchAnim = options.punchAnim ?? "punching"; // 攻击动画名
this._deathAnim = options.deathAnim ?? "dying"; // 死亡动画名
// ==================== 运行状态 ====================
this._zombies = new Map(); // id → ZombieEntity
this._wave = 0; // 当前波次
this._nextId = 0; // 自增 id 计数器
}
// ==================== 波次 / 生成 ====================
// 按波次配置批量生成丧尸
async startWave(waveConfig = {}) {
if (!this._loader || !this._colliders.length || !this._modelUrl) {
console.warn("[ZombieManager] Missing loader, colliders or modelUrl");
return;
}
this._wave += 1;
const origin = waveConfig.origin?.clone?.() ?? waveConfig.origin ?? new Vector3();
const count = waveConfig.count ?? 5;
const radius = waveConfig.radius ?? 10; // 默认半径 10
const spawnHeight = waveConfig.spawnHeight ?? 0.5; // 对应参数 y
let spawnPoints = waveConfig.spawnPoints;
// 如果没有预设点,则根据半径和高度生成随机点
if (!spawnPoints) {
spawnPoints = [];
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const r = Math.sqrt(Math.random()) * radius; // 使用开方确保在圆盘内分布均匀
const x = Math.cos(angle) * r;
const z = Math.sin(angle) * r;
spawnPoints.push(new Vector3(origin.x + x, origin.y + spawnHeight, origin.z + z));
}
}
await Promise.all(spawnPoints.map((point) => this.spawnZombie(point)));
}
// 在指定位置生成单个丧尸,返回其 id
async spawnZombie(position) {
const id = `zombie_${this._nextId++}`;
const entity = new ZombieEntity(this._scene, id);
await entity.load(this._loader, {
modelUrl: this._modelUrl,
colliders: this._colliders,
position: position.clone?.() ?? new Vector3(position.x, position.y, position.z),
scale: this._scale,
walkAnim: this._walkAnim,
runAnim: this._runAnim,
idleAnim: this._idleAnim,
punchAnim: this._punchAnim,
deathAnim: this._deathAnim,
rotateY: this._rotateY,
speed: this._speed,
});
this._zombies.set(id, entity);
return id;
}
// ==================== 命中 ====================
// 武器命中回调,转发伤害给对应实体
onHit(id, damage) {
const entity = this._zombies.get(id);
if (!entity || entity.isDead) return;
entity.takeDamage(damage);
}
// ==================== 主循环 ====================
update(dt, playerPos) {
if (!playerPos) return;
for (const [id, entity] of this._zombies.entries()) {
entity.update(dt, playerPos);
// 如果丧尸已死亡且超过10秒,则彻底移除
if (entity.isDead && entity.getDeathTime() >= 10) {
entity.destroy();
this._zombies.delete(id);
}
}
this._resolveZombieOverlaps();
}
// 解决丧尸之间的胶囊重叠(两两推开,仅处理水平方向)
_resolveZombieOverlaps() {
const entities = Array.from(this._zombies.values()).filter(e => !e.isDead);
for (let i = 0; i < entities.length; i++) {
const a = entities[i];
const aCapsule = a.getCapsule();
const aInfo = a.getCapsuleInfo();
if (!aCapsule || !aInfo) continue;
for (let j = i + 1; j < entities.length; j++) {
const b = entities[j];
const bCapsule = b.getCapsule();
const bInfo = b.getCapsuleInfo();
if (!bCapsule || !bInfo) continue;
const dx = bCapsule.position.x - aCapsule.position.x;
const dz = bCapsule.position.z - aCapsule.position.z;
const distSq = dx * dx + dz * dz;
const minDist = aInfo.radius + bInfo.radius;
if (distSq >= minDist * minDist || distSq <= 1e-10) continue;
const dist = Math.sqrt(distSq);
const half = (minDist - dist) * 0.5; // 各推开一半
const nx = dx / dist;
const nz = dz / dist;
aCapsule.position.x -= nx * half;
aCapsule.position.z -= nz * half;
bCapsule.position.x += nx * half;
bCapsule.position.z += nz * half;
}
}
}
// ==================== 销毁 ====================
destroy() {
for (const entity of this._zombies.values()) {
entity.destroy();
}
this._zombies.clear();
}
}