studio/src/editor/engine/WeaponSystem.js
min 931d53b4d9 fix(studio): бластер от 3-го лица стреляет в точку клика, а не в центр камеры
При свободном курсоре (нет pointer-lock, 3-е лицо) выстрел шёл из getForwardRay
(фокус камеры). Теперь onDown берёт координаты клика → setAimScreenPoint → луч
через точку клика; onMove обновляет _holdAim для авто-огня при удержании. При
pointer-lock (1-е лицо, курсор в центре) — прежнее поведение (центр).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:01:14 +03:00

882 lines
39 KiB
JavaScript
Raw 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.

/**
* 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;
// Если курсор СВОБОДЕН (нет pointer-lock — обычно 3-е лицо) — стреляем
// ТУДА, КУДА КЛИКНУЛИ, а не в центр камеры. При pointer-lock курсор в
// центре экрана → используем прицел камеры (aim не задаём).
if (document.pointerLockElement !== canvas) {
const rect = canvas.getBoundingClientRect();
const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
this.setAimScreenPoint(cx * (canvas.width / rect.width),
cy * (canvas.height / rect.height));
}
}
this._mouseDown = true;
this._tryFire();
};
const onUp = (e) => {
if (e.button !== 0) return;
this._mouseDown = false;
};
// При свободном курсоре (3-е лицо) запоминаем позицию мыши — чтобы
// авто-огонь при удержании ЛКМ продолжал стрелять в точку курсора.
const onMove = (e) => {
if (!this._mouseDown) return;
if (document.pointerLockElement === canvas) return;
const rect = canvas.getBoundingClientRect();
const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
this._holdAim = { x: cx * (canvas.width / rect.width), y: cy * (canvas.height / rect.height) };
}
};
const onKey = (e) => {
if (e.code === 'KeyR') this.reload();
};
canvas.addEventListener('mousedown', onDown);
window.addEventListener('mouseup', onUp);
window.addEventListener('mousemove', onMove);
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: 'mousemove', fn: onMove });
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;
// aim: разовый клик (_aimScreenPoint) или удержание по курсору (_holdAim,
// только когда курсор свободен — нет pointer-lock).
let aim = this._aimScreenPoint;
if (!aim && this._holdAim && document.pointerLockElement !== this.scene3d?.canvas) {
aim = this._holdAim;
}
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 */ }
}
}