Files
knsoandClaude Opus 5 fb38652e7f 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>
2026-09-15 21:27:03 +02:00

230 lines
7.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}