Open-source веб-студия для создания игр Рублокса, двойная лицензия AGPL-3.0 + Коммерческая. Главное: - Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16 - Самодостаточный движок ~28к строк (66 файлов): BlockManager, TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов - Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco) - Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn) - Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt) - 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.) - Конфигурируемый бэкенд через VITE_API_BASE — работает со staging (dev-api.rublox.pro) без настройки - Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка - Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - ESLint + Prettier + EditorConfig - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Перед публикацией: - Все импорты из minecraftia заменены на локальные - Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env - Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо) - AdminKubikonModeration не публикуется (модерация — в team.rublox.pro) - 93 МБ ассетов public/kubikon-assets вынесены в .gitignore (раздаются через release artifact)
851 lines
37 KiB
JavaScript
851 lines
37 KiB
JavaScript
/**
|
||
* 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 */ }
|
||
}
|
||
}
|