/** * WeaponSystem — отображение оружия в руке игрока + стрельба. * * - Загружает модель текущего оружия (из активного слота инвентаря) и крепит её * к камере как «view-model» (видна в Play, держится в правом нижнем углу). * - Обрабатывает ЛКМ: пускает луч от камеры, ищет цель (модель/блок/примитив), * спавнит трассер-эффект и звук, отдаёт callback onHit для логики урона. * * Использование: * const ws = new WeaponSystem(scene3d); * ws.start(); // включить view-model и обработку ЛКМ * ws.equip(weaponItem); // weaponItem из inventory * ws.unequip(); // убрать модель из руки * ws.stop(); // выключить * * ws.setOnHit((target, distance, point) => {...}); * * Стрельба автоматически вызывается при нажатой ЛКМ в Play (с cooldown'ом по * fireRate). Для одиночных выстрелов оружие может быть с params.auto=false. */ import { Vector3, Color3, Color4, MeshBuilder, StandardMaterial, ParticleSystem, Texture, } from '@babylonjs/core'; import { getModelType } from './ModelTypes'; // Базовые offsets для view-model. Могут быть переопределены через // model.gameplay.viewModel в ModelTypes (для каждого оружия — свои). const VIEW_MODEL_OFFSET = { x: 0.35, y: -0.28, z: 0.6 }; const HAND_OFFSET = { x: 0, y: 0, z: 0 }; const HAND_ROTATION = { x: 0, y: 0, z: 0 }; const HAND_SCALE_3RD = 1.4; const HAND_SCALE_1ST = 0.7; // Получить настройки view-model для оружия (с fallback на дефолты) function _getViewModelSettings(equipped) { const vm = equipped?.params?.viewModel || equipped?.viewModel || (equipped?.modelTypeId ? getModelType(equipped.modelTypeId)?.gameplay?.viewModel : null); return { scale3rd: vm?.scale3rd ?? HAND_SCALE_3RD, scale1st: vm?.scale1st ?? HAND_SCALE_1ST, position3rd: vm?.position3rd ?? HAND_OFFSET, rotation3rd: vm?.rotation3rd ?? HAND_ROTATION, position1st: vm?.position1st ?? VIEW_MODEL_OFFSET, }; } export class WeaponSystem { constructor(scene3d) { this.scene3d = scene3d; this.scene = scene3d.scene; this._active = false; this._equipped = null; // активный item из inventory this._viewMeshes = []; // меши view-model'и this._lastFireTime = 0; this._mouseDown = false; this._onHit = null; this._listeners = []; // Патроны: { magazine: текущий магазин, reserve: запас } // Хранится по slot-id (id предмета в инвентаре), чтобы не сбрасывать при смене. this._ammoState = new Map(); this._reloading = false; this._reloadEndTime = 0; // Колбэк UI: ({ magazine, magazineMax, reserve, reloading, reloadProgress }) this._onAmmoChange = null; } setOnAmmoChange(cb) { this._onAmmoChange = cb; } setOnHit(cb) { this._onHit = cb; } /** * Tap-to-shoot для мобилы: следующий выстрел будет нацелен в точку * (x, y) на экране (canvas-координаты), а не в центр камеры. * Применяется один раз в _fire(), потом сбрасывается. */ setAimScreenPoint(x, y) { this._aimScreenPoint = { x, y }; } /** Запустить — навешивает обработчики ЛКМ на canvas. */ start() { if (this._active) return; this._active = true; const canvas = this.scene3d.canvas; const onDown = (e) => { if (e.button !== 0) return; // Если UI-режим курсора — не стреляем (мышь работает по GUI) if (this.scene3d?.player?.isUiCursorMode?.()) return; this._mouseDown = true; this._tryFire(); }; const onUp = (e) => { if (e.button !== 0) return; this._mouseDown = false; }; const onKey = (e) => { if (e.code === 'KeyR') this.reload(); }; canvas.addEventListener('mousedown', onDown); window.addEventListener('mouseup', onUp); window.addEventListener('keydown', onKey); this._listeners.push({ target: canvas, type: 'mousedown', fn: onDown }); this._listeners.push({ target: window, type: 'mouseup', fn: onUp }); this._listeners.push({ target: window, type: 'keydown', fn: onKey }); // Регистрируем перед-кадровый хук для авто-стрельбы (если auto=true) this._renderHook = () => this._tick(); this.scene.registerBeforeRender(this._renderHook); // === DEBUG: подкрутка оружия в реальном времени из консоли === // weapon3rd(scale, x, y, z, rx, ry, rz) — все опциональны // weaponInfo() — печатает текущее состояние const ws = this; window.weapon3rd = function(scale, x, y, z, rx, ry, rz) { const r = ws._viewRoot; if (!r) { console.log('[weapon] not equipped or not 3rd person'); return; } if (Number.isFinite(scale)) r.scaling.set(scale, scale, scale); if (Number.isFinite(x) || Number.isFinite(y) || Number.isFinite(z)) { r.position.set(x ?? r.position.x, y ?? r.position.y, z ?? r.position.z); } if (Number.isFinite(rx) || Number.isFinite(ry) || Number.isFinite(rz)) { r.rotation.set(rx ?? r.rotation.x, ry ?? r.rotation.y, rz ?? r.rotation.z); } console.log('[weapon] scale=', r.scaling.x.toFixed(2), 'pos=', r.position.toString(), 'rot=', r.rotation.toString()); }; window.weaponInfo = function() { const r = ws._viewRoot; if (!r) { console.log('[weapon] not equipped'); return; } console.log('[weapon] equipped=', ws._equipped?.modelTypeId, 'parent=', r.parent?.name, 'scale=', r.scaling.toString(), 'pos(local)=', r.position.toString(), 'rot=', r.rotation.toString()); r.computeWorldMatrix(true); console.log('[weapon] absPos=', r.absolutePosition?.toString()); const player = ws.scene3d?.player; const arm = player?._rightArmMeshes?.[0]; if (arm) { arm.computeWorldMatrix(true); console.log('[arm] absPos=', arm.absolutePosition?.toString()); } const anchor = player?.getWeaponAnchor?.(); if (anchor) { anchor.computeWorldMatrix(true); console.log('[anchor] absPos=', anchor.absolutePosition?.toString()); } }; } stop() { if (!this._active) return; this._active = false; for (const { target, type, fn } of this._listeners) { try { target.removeEventListener(type, fn); } catch (e) { /* ignore */ } } this._listeners = []; if (this._renderHook) { this.scene.unregisterBeforeRender(this._renderHook); this._renderHook = null; } this.unequip(); this._mouseDown = false; } /** Снарядить оружие из инвентаря (item={modelTypeId,name,params}). */ async equip(item) { // Снимаем предыдущее this.unequip(); if (!item || item.kind !== 'weapon' || !item.modelTypeId) return; this._equipped = item; // Инициализируем магазин если впервые экипируем это оружие if (!this._ammoState.has(item.id)) { const p = item.params || {}; const magMax = p.magazine ?? 30; const reserve = p.reserve ?? 90; this._ammoState.set(item.id, { magazine: magMax, reserve }); } this._notifyAmmoChange(); try { await this._loadAndAttachViewModel(item.modelTypeId); } catch (e) { // eslint-disable-next-line no-console console.warn('[WeaponSystem] failed to load weapon model:', e); } } /** Получить состояние патронов текущего оружия. */ getAmmoState() { if (!this._equipped) return null; const st = this._ammoState.get(this._equipped.id); if (!st) return null; const p = this._equipped.params || {}; const magMax = p.magazine ?? 30; let reloadProgress = 0; if (this._reloading) { const dur = (p.reloadTime ?? 1.5) * 1000; const elapsed = dur - (this._reloadEndTime - performance.now()); reloadProgress = Math.max(0, Math.min(1, elapsed / dur)); } return { magazine: st.magazine, magazineMax: magMax, reserve: st.reserve, reloading: this._reloading, reloadProgress, }; } _notifyAmmoChange() { if (this._onAmmoChange) { try { this._onAmmoChange(this.getAmmoState()); } catch (e) { /* ignore */ } } } /** Дёргается из render-loop каждый кадр. Пока идёт перезарядка — раз * в ~50мс шлёт прогресс, чтобы полоска плавно заполнялась. */ tick() { if (!this._reloading) return; const now = performance.now(); if (this._lastReloadNotify && now - this._lastReloadNotify < 50) return; this._lastReloadNotify = now; this._notifyAmmoChange(); } /** Запустить перезарядку. Длится params.reloadTime секунд. */ reload() { if (!this._equipped || this._reloading) return; const p = this._equipped.params || {}; const magMax = p.magazine ?? 30; const st = this._ammoState.get(this._equipped.id); if (!st) return; if (st.magazine >= magMax) return; // уже полный if (st.reserve <= 0) return; // нечем перезаряжать const reloadTime = p.reloadTime ?? 1.5; this._reloading = true; this._reloadEndTime = performance.now() + reloadTime * 1000; this._notifyAmmoChange(); // Небольшой звук «перезарядки» this._playReloadSound(); // Завершение через таймер setTimeout(() => { if (!this._reloading) return; // была отмена/смена оружия const cur = this._ammoState.get(this._equipped?.id); if (cur) { const need = magMax - cur.magazine; const take = Math.min(need, cur.reserve); cur.magazine += take; cur.reserve -= take; } this._reloading = false; this._notifyAmmoChange(); }, reloadTime * 1000); } unequip() { for (const m of this._viewMeshes) { try { m.dispose(); } catch (e) {} } this._viewMeshes = []; this._viewRoot = null; if (this._retryTimer) { clearTimeout(this._retryTimer); this._retryTimer = null; } // Отменяем перезарядку — оружие сменилось this._reloading = false; this._equipped = null; // Прячем вытянутую руку (если была показана) const player = this.scene3d?.player; if (player && typeof player._updateExtendedArm === 'function') { player._updateExtendedArm(false); } this._notifyAmmoChange(); } /** * Загрузить модель оружия и прикрепить либо к камере (1-st person), * либо к корню модели игрока (3-rd person). Модель не пикается лучом. */ async _loadAndAttachViewModel(modelTypeId) { const mm = this.scene3d.modelManager; if (!mm) return; const proto = await mm._loadPrototype(modelTypeId); if (!proto || !this._active || this._equipped?.modelTypeId !== modelTypeId) return; // Инстанцируем — instantiateModelsToScene клонирует со всеми трансформациями const result = proto.instantiateModelsToScene(); const meshes = result.rootNodes.flatMap(n => [n, ...(n.getChildMeshes ? n.getChildMeshes() : [])]); const root = result.rootNodes[0]; if (!root) return; // Все меши делаем не-пикабельными for (const m of meshes) { if (m && m.isPickable !== undefined) m.isPickable = false; } this._viewRoot = root; this._viewMeshes = [root, ...meshes].filter(Boolean); // Применяем текущий режим камеры this._applyAttachment(); } /** * Прикрепить view-model к нужному носителю в зависимости от camera mode. * - first → к camera, рендерим поверх всего (renderingGroupId=1) * - third/front → к _modelRoot игрока, рендерим как обычно */ _applyAttachment() { const root = this._viewRoot; if (!root) return; const player = this.scene3d?.player; const mode = player?._cameraMode || 'first'; const camera = this.scene.activeCamera; const vm = _getViewModelSettings(this._equipped); if (mode === 'first' && camera) { if (player && typeof player._updateExtendedArm === 'function') { player._updateExtendedArm(false); } root.parent = camera; root.position.set(vm.position1st.x, vm.position1st.y, vm.position1st.z); root.rotation.set(0, Math.PI, 0); root.scaling.set(vm.scale1st, vm.scale1st, vm.scale1st); for (const m of this._viewMeshes) { if (m && m.renderingGroupId !== undefined) m.renderingGroupId = 1; } } else { if (player && typeof player._updateExtendedArm === 'function') { player._updateExtendedArm(true); } const anchor = player?.getWeaponAnchor?.(); if (!anchor) { root.parent = null; for (const m of this._viewMeshes) { if (m && m.setEnabled) m.setEnabled(false); } this._retryAttach(); return; } for (const m of this._viewMeshes) { if (m && m.setEnabled) m.setEnabled(true); if (m && m.renderingGroupId !== undefined) m.renderingGroupId = 0; } root.parent = anchor; root.position.set(vm.position3rd.x, vm.position3rd.y, vm.position3rd.z); root.rotation.set(vm.rotation3rd.x, vm.rotation3rd.y, vm.rotation3rd.z); root.scaling.set(vm.scale3rd, vm.scale3rd, vm.scale3rd); } } /** Повтор попытки прицепить к модели игрока, если она ещё не загрузилась. */ _retryAttach() { if (this._retryTimer) return; this._retryTimer = setTimeout(() => { this._retryTimer = null; if (!this._active || !this._viewRoot) return; this._applyAttachment(); }, 200); } /** Колбэк из PlayerController при смене 1st/3rd. */ onCameraModeChange(_mode) { this._applyAttachment(); } _tick() { if (!this._active) return; // Анимация замаха меча (даже без equipped — для затухания) this._tickMelee(); if (!this._equipped) return; const params = this._equipped.params || {}; const isAuto = params.auto !== false; if (!isAuto) return; if (this._mouseDown) this._tryFire(); } _tryFire() { if (!this._equipped) return; const params = this._equipped.params || {}; const isMelee = params.weaponKind === 'melee' || this._equipped.modelTypeId?.startsWith('weapon-') || this._equipped.modelTypeId === 'weapon-sword' || this._equipped.modelTypeId === 'weapon-spear'; const now = performance.now() / 1000; if (isMelee) { // Меч/копьё — без патронов, только cooldown const fireRate = Math.max(0.1, params.fireRate ?? 0.6); if (now - this._lastFireTime < fireRate) return; this._lastFireTime = now; this._meleeAttack(); return; } if (this._reloading) return; const st = this._ammoState.get(this._equipped.id); if (!st) return; if (st.magazine <= 0) { if (st.reserve > 0) this.reload(); return; } const fireRate = Math.max(0.05, params.fireRate ?? 0.18); if (now - this._lastFireTime < fireRate) return; this._lastFireTime = now; st.magazine -= 1; this._notifyAmmoChange(); this._fire(); if (st.magazine <= 0 && st.reserve > 0) { this.reload(); } } /** * Удар ближнего боя. * Запускает анимацию взмаха (300мс), урон срабатывает в её середине. */ _meleeAttack() { const player = this.scene3d?.player; const camera = this.scene.activeCamera; if (!player || !camera) return; const isSpear = this._equipped?.modelTypeId === 'weapon-spear'; // Запускаем анимацию (тип в зависимости от оружия) if (player._rightArmMeshes?.[0]) { const meshName = (player._rightArmMeshes[0].name || '').replace(/^player_/, ''); this._meleeAnimStart = performance.now(); this._meleeAnimMeshName = meshName; this._meleeAnimType = isSpear ? 'thrust' : 'swing'; this._meleeDamageDealt = false; // флаг чтобы урон сработал один раз в середине } this._playMeleeSound(); } /** Применить урон зомби в конусе/радиусе. Вызывается в середине анимации. */ _applyMeleeDamage() { const player = this.scene3d?.player; const camera = this.scene.activeCamera; if (!player || !camera) return; const params = this._equipped.params || {}; const range = params.range ?? 2.2; const arc = params.meleeArc ?? 1.2; const damage = params.damage ?? 40; const fwd = camera.getForwardRay(1).direction.clone(); fwd.y = 0; if (fwd.lengthSquared() < 0.001) return; fwd.normalize(); const px = player._pos.x; const py = player._pos.y; const pz = player._pos.z; const zm = this.scene3d.zombieManager; if (!zm || !zm.zombies) return; for (const z of zm.zombies.values()) { const dx = z.data.x - px; const dz = z.data.z - pz; const dy = z.data.y + 1 - py; const dist = Math.sqrt(dx * dx + dz * dz + dy * dy); if (dist > range) continue; const len = Math.sqrt(dx * dx + dz * dz) || 0.001; const dot = (dx / len) * fwd.x + (dz / len) * fwd.z; const angle = Math.acos(Math.max(-1, Math.min(1, dot))); if (angle > arc / 2) continue; z.hp -= damage; if (z.hp <= 0) zm._killZombie(z); } } /** * Анимация удара. Тип: * 'swing' — широкий взмах сверху вниз (меч). * 'thrust' — выпад вперёд (копьё). */ _tickMelee() { if (!this._meleeAnimStart) return; const player = this.scene3d?.player; if (!player) return; const elapsed = (performance.now() - this._meleeAnimStart) / 1000; const dur = 0.32; const k = Math.min(1, elapsed / dur); // Урон применяется один раз в середине анимации (k≈0.5) if (!this._meleeDamageDealt && k >= 0.5) { this._meleeDamageDealt = true; this._applyMeleeDamage(); } if (elapsed >= dur) { if (player.setMeshRotationOverride && this._meleeAnimMeshName) { player.setMeshRotationOverride(this._meleeAnimMeshName, new Vector3(-Math.PI / 2, 0, 0)); } this._meleeAnimStart = null; return; } if (!player.setMeshRotationOverride || !this._meleeAnimMeshName) return; if (this._meleeAnimType === 'thrust') { // Копьё: рука уходит назад на 30%, затем стремительно вперёд // и возвращается. Реализуем через rotation.x движение. // k=0..0.3 — отвод назад (rotation.x от -π/2 до -π/2 - 0.6) // k=0.3..0.6 — резкий бросок вперёд (до -π/2 + 0.4) // k=0.6..1.0 — возврат let off; if (k < 0.3) { off = -0.6 * (k / 0.3); } else if (k < 0.6) { off = -0.6 + 1.0 * ((k - 0.3) / 0.3); } else { off = 0.4 * (1 - (k - 0.6) / 0.4); } player.setMeshRotationOverride(this._meleeAnimMeshName, new Vector3(-Math.PI / 2 + off, 0, 0)); } else { // Меч: широкий sweep сверху → вниз (по дуге). // Поднимаем руку выше, потом резко опускаем. // k=0..0.3 — замах вверх (rotation.x от -π/2 → -π) // k=0.3..0.7 — резкий взмах вниз (до 0, ниже плеча) // k=0.7..1.0 — возврат к -π/2 let armX; if (k < 0.3) { armX = -Math.PI / 2 - (Math.PI / 2) * (k / 0.3); // → -π } else if (k < 0.7) { const t = (k - 0.3) / 0.4; armX = -Math.PI + (Math.PI) * t; // -π → 0 } else { const t = (k - 0.7) / 0.3; armX = 0 + (-Math.PI / 2 - 0) * t; // 0 → -π/2 } // Также добавляем небольшой Z-крен для широкого свинга const armZ = Math.sin(k * Math.PI) * 0.3; player.setMeshRotationOverride(this._meleeAnimMeshName, new Vector3(armX, 0, armZ)); } } /** Звук удара меча — короткий «свист». */ _playMeleeSound() { try { if (!this._audioCtx) { const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return; this._audioCtx = new Ctx(); } const ctx = this._audioCtx; if (ctx.state === 'suspended') ctx.resume(); const t = ctx.currentTime; const dur = 0.18; const osc = ctx.createOscillator(); osc.type = 'triangle'; osc.frequency.setValueAtTime(800, t); osc.frequency.exponentialRampToValueAtTime(200, t + dur); const g = ctx.createGain(); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.16, t + 0.02); g.gain.exponentialRampToValueAtTime(0.001, t + dur); osc.connect(g).connect(ctx.destination); osc.start(t); osc.stop(t + dur + 0.02); } catch (e) { /* ignore */ } } /** Один выстрел: луч, эффекты, звук, callback onHit. */ _fire() { const camera = this.scene.activeCamera; if (!camera) return; const params = this._equipped?.params || {}; const range = params.range ?? 60; const damage = params.damage ?? 25; const player = this.scene3d?.player; const mode = player?._cameraMode || 'first'; // Прицеливание: по умолчанию от камеры (центр прицела). Но если // _aimScreenPoint задан — стреляем в точку (x,y) на экране // (для tap-to-shoot на мобиле). Точка применяется один раз. let hit = null; let ray; const aim = this._aimScreenPoint; try { if (aim) { ray = this.scene.createPickingRay(aim.x, aim.y, null, camera); this._aimScreenPoint = null; // одноразовая } else { ray = camera.getForwardRay(range); } hit = this.scene.pickWithRay(ray, (mesh) => { if (!mesh || !mesh.isPickable) return false; // Игнорим скрытые меши (Babylon иногда пикает disabled-mesh // когда родитель — TransformNode с setEnabled(false)). if (typeof mesh.isEnabled === 'function' && !mesh.isEnabled()) return false; // Спавн-маркер игрока (`isSpawn`) и любая редакторская служебная меш if (mesh.metadata?.isSpawn) return false; if (this._viewMeshes.includes(mesh)) return false; if (this.scene3d?.player?._modelMeshes?.includes?.(mesh)) return false; if (mesh.metadata?.isWater) return false; return true; }); } catch (e) { /* ignore */ } const camOrigin = camera.globalPosition.clone(); // Если был aim-ray от тапа — используем его направление, иначе камеры. const camFwd = (ray && ray.direction) ? ray.direction.clone().normalize() : camera.getForwardRay(range).direction.clone().normalize(); const hitPoint = hit?.pickedPoint || camOrigin.add(camFwd.scale(range)); const hitDistance = hit?.distance ?? range; // Точка вылета ТРАССЕРА = конец ствола view-model. // В 1-st person — _viewRoot + 0.4 по camera.forward. // В 3-rd person — _viewRoot + локальный forward оружия (его +Z в мире, // т.к. оружие повёрнуто rotation=(0,0,0) внутри якоря модели). let muzzlePos; if (this._viewRoot && this._viewRoot.getWorldMatrix) { this._viewRoot.computeWorldMatrix(true); muzzlePos = this._viewRoot.absolutePosition.clone(); if (mode === 'first') { muzzlePos = muzzlePos.add(camFwd.scale(0.4)); } else { // Forward оружия в мире: _viewRoot.getDirection(Vector3.Forward()) // даёт мировое направление локального +Z. Длина ствола ~0.5. const weaponFwd = this._viewRoot.getDirection ? this._viewRoot.getDirection(new Vector3(0, 0, 1)).normalize() : camFwd; muzzlePos = muzzlePos.add(weaponFwd.scale(0.5)); } } else { muzzlePos = camOrigin.add(camFwd.scale(0.6)); } // Трассер — летящая «капля света» от дула к точке попадания. // В 1-st person дуло близко к камере и направление трассера ≈ направление // взгляда → визуально это полоска улетающая вдаль (не вспышка перед лицом). this._spawnTracer(muzzlePos, hitPoint, mode); // Вспышка из дула — тоже от muzzlePos this._spawnMuzzleFlash(muzzlePos); // Импакт-частицы в точке попадания if (hit?.pickedMesh) { this._spawnImpact(hitPoint); } // Звук this._playFireSound(); // Колбэк попадания if (hit?.pickedMesh && this._onHit) { try { this._onHit({ mesh: hit.pickedMesh, point: { x: hitPoint.x, y: hitPoint.y, z: hitPoint.z }, distance: hitDistance, damage, metadata: hit.pickedMesh.metadata || null, }); } catch (e) { /* ignore */ } } } /** * Трейсер выстрела: «капля света» — сфера, летящая от дула к точке попадания. * За ней тянется удлинённый цилиндр-хвост (в направлении полёта). * * Сферу видно с любого ракурса (в отличие от тонкого статичного цилиндра, * который в 3rd-person смотрел вдоль камеры и сжимался в точку). * renderingGroupId=1 + alwaysSelectAsActiveMesh — рендерится поверх всего * и не отбрасывается octree/freeze. */ _spawnTracer(start, end, mode) { const dir = end.subtract(start); const len = dir.length(); if (len < 0.05) return; const dirN = dir.normalize(); // Сфера-снаряд (видна со всех сторон, нет проблемы тонкого цилиндра вдоль взгляда) const PROJ_DIAMETER = mode === 'first' ? 0.10 : 0.20; const proj = MeshBuilder.CreateSphere('tracerProj', { diameter: PROJ_DIAMETER, segments: 6, }, this.scene); const mat = new StandardMaterial('tracerProjMat', this.scene); mat.emissiveColor = new Color3(1, 0.85, 0.2); mat.diffuseColor = new Color3(1, 0.85, 0.2); mat.disableLighting = true; proj.material = mat; proj.isPickable = false; proj.alwaysSelectAsActiveMesh = true; proj.renderingGroupId = 1; // Хвост — удлинённый цилиндр позади снаряда (длина 1.5м, ориентирован по dirN). // Тоже renderingGroupId=1, чтобы не терялся за геометрией. const TAIL_LEN = mode === 'first' ? 1.0 : 2.0; const TAIL_DIAM = mode === 'first' ? 0.06 : 0.14; const tail = MeshBuilder.CreateCylinder('tracerTail', { height: TAIL_LEN, diameter: TAIL_DIAM, tessellation: 6, }, this.scene); const tailMat = new StandardMaterial('tracerTailMat', this.scene); tailMat.emissiveColor = new Color3(1, 0.7, 0.1); tailMat.diffuseColor = new Color3(1, 0.7, 0.1); tailMat.disableLighting = true; tailMat.alpha = 0.7; tail.material = tailMat; tail.isPickable = false; tail.alwaysSelectAsActiveMesh = true; tail.renderingGroupId = 1; // Поворот цилиндра: в Babylon он вертикальный (Y), нужно вдоль dirN. const pitch = -Math.asin(dirN.y); const yawAngle = Math.atan2(dirN.x, dirN.z); tail.rotation.set(Math.PI / 2 + pitch, yawAngle, 0); const speed = mode === 'first' ? 140 : 100; const flightTime = Math.min(0.5, len / speed); const startTs = performance.now(); proj.position.copyFrom(start); tail.position.copyFrom(start); const obs = this.scene.onBeforeRenderObservable.add(() => { const t = (performance.now() - startTs) / 1000; const k = Math.min(1, t / flightTime); const cur = start.add(dirN.scale(len * k)); proj.position.copyFrom(cur); // Хвост ставим в середине между текущей позицией снаряда // и (cur - TAIL_LEN/2 * dirN), чтобы тянулся ПОЗАДИ снаряда. tail.position.copyFrom(cur.subtract(dirN.scale(TAIL_LEN * 0.5))); if (k >= 1) { this.scene.onBeforeRenderObservable.remove(obs); try { proj.dispose(); mat.dispose(); } catch (e) {} try { tail.dispose(); tailMat.dispose(); } catch (e) {} } }); } /** Жёлтая вспышка частиц у дула на 100мс. */ _spawnMuzzleFlash(origin) { const camera = this.scene.activeCamera; if (!camera || !origin) return; const fwd = camera.getForwardRay(1).direction.normalize(); const ps = new ParticleSystem('muzzle', 30, this.scene); ps.particleTexture = new Texture('https://www.babylonjs-playground.com/textures/flare.png', this.scene); ps.emitter = origin; ps.minEmitBox = new Vector3(-0.05, -0.05, -0.05); ps.maxEmitBox = new Vector3(0.05, 0.05, 0.05); ps.color1 = new Color4(1, 0.9, 0.2, 1); ps.color2 = new Color4(1, 0.5, 0, 1); ps.colorDead = new Color4(0.1, 0, 0, 0); ps.minSize = 0.1; ps.maxSize = 0.3; ps.minLifeTime = 0.05; ps.maxLifeTime = 0.15; ps.emitRate = 200; ps.direction1 = fwd.scale(2); ps.direction2 = fwd.scale(4); ps.minEmitPower = 1; ps.maxEmitPower = 3; ps.start(); setTimeout(() => { try { ps.stop(); setTimeout(() => ps.dispose(), 300); } catch (e) {} }, 80); } /** Искры в точке попадания. */ _spawnImpact(point) { const ps = new ParticleSystem('impact', 25, this.scene); ps.particleTexture = new Texture('https://www.babylonjs-playground.com/textures/flare.png', this.scene); ps.emitter = point; ps.minEmitBox = new Vector3(-0.1, -0.1, -0.1); ps.maxEmitBox = new Vector3(0.1, 0.1, 0.1); ps.color1 = new Color4(1, 0.7, 0.2, 1); ps.color2 = new Color4(0.8, 0.3, 0, 1); ps.colorDead = new Color4(0, 0, 0, 0); ps.minSize = 0.08; ps.maxSize = 0.18; ps.minLifeTime = 0.15; ps.maxLifeTime = 0.4; ps.emitRate = 100; ps.direction1 = new Vector3(-2, -2, -2); ps.direction2 = new Vector3(2, 2, 2); ps.gravity = new Vector3(0, -5, 0); ps.minEmitPower = 1; ps.maxEmitPower = 3; ps.start(); setTimeout(() => { try { ps.stop(); setTimeout(() => ps.dispose(), 500); } catch (e) {} }, 100); } /** Звук перезарядки — два щелчка с интервалом ~0.3с. */ _playReloadSound() { try { if (!this._audioCtx) { const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return; this._audioCtx = new Ctx(); } const ctx = this._audioCtx; if (ctx.state === 'suspended') ctx.resume(); const click = (when) => { const t = ctx.currentTime + when; const osc = ctx.createOscillator(); osc.type = 'square'; osc.frequency.setValueAtTime(120, t); osc.frequency.exponentialRampToValueAtTime(40, t + 0.08); const g = ctx.createGain(); g.gain.setValueAtTime(0.18, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.1); osc.connect(g).connect(ctx.destination); osc.start(t); osc.stop(t + 0.12); }; click(0); click(0.4); } catch (e) { /* ignore */ } } /** Процедурный звук «пиу» — короткий sweep частоты. */ _playFireSound() { try { if (!this._audioCtx) { const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return; this._audioCtx = new Ctx(); } const ctx = this._audioCtx; if (ctx.state === 'suspended') ctx.resume(); const t = ctx.currentTime; const dur = 0.12; const osc = ctx.createOscillator(); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(900, t); osc.frequency.exponentialRampToValueAtTime(150, t + dur); const g = ctx.createGain(); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.18, t + 0.005); g.gain.exponentialRampToValueAtTime(0.001, t + dur); const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2400; osc.connect(lp).connect(g).connect(ctx.destination); osc.start(t); osc.stop(t + dur + 0.02); } catch (e) { /* ignore */ } } }