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