Compare commits

..

No commits in common. "4144fb47cc792c2b052138c687d4bd2c0201ab8e" and "5d1f515f42accb7e2b150ff443006f59f8d0f7a2" have entirely different histories.

17 changed files with 25 additions and 2264 deletions

View File

@ -1,5 +0,0 @@
VITE_API_BASE=https://minecraftia-school.ru
VITE_REALTIME_HTTP=https://minecraftia-school.ru/api-game
VITE_REALTIME_WS=wss://minecraftia-school.ru/api-game
VITE_RUBLOX_HOME=https://rublox.pro/app
VITE_STANDALONE=false

41
.gitignore vendored
View File

@ -41,43 +41,4 @@ public/kubikon-assets/
# OS
Thumbs.db
# ============================================================
# SECURITY — добавлено после взлома 2026-06-04
# НИКОГДА не коммитить эти файлы — они могут содержать секреты!
# ============================================================
CLAUDE.md
INFO_PROCESS.md
PASSWORD_*.md
SECRETS*
*_SECRETS*
*.kdbx
*.kdbx.bak
.env
.env.*
!.env.example
!.env.sample
# .env.production содержит ТОЛЬКО публичные URL (api-base, realtime, rublox.pro)
# — без секретов. Нужен в git, чтобы CI собирал прод-бандл с правильным
# VITE_API_BASE (иначе API уходит на origin вместо minecraftia-school.ru,
# redeem-ticket падает → плеер выбивает на /app). Инцидент 2026-06-07.
!.env.production
secrets/
*.pem
*.key
id_rsa
id_ed25519
known_hosts
authorized_keys
# Текстовые заметки разработчика (могут содержать всё что угодно)
NOTES*.md
TODO*.md
PRIVATE*.md
INTERNAL_*.md
# Бэкапы кода с предыдущих версий
*.bak
*.bak_*
BackUp/
backup/
.env.production

View File

@ -1,249 +0,0 @@
/**
* AchievementsManager достижения (badges) как в Roblox (задача 20).
*
* - define([...]) регистрирует достижения проекта.
* - unlock(id) разблокирует toast справа-сверху (4 редкости, очередь, звук).
* - bindToStat(id, statName, {gte/lte/eq}) авто-unlock по leaderstat.
* - кнопка-кубок слева-снизу страница «Мои достижения» (grid + прогресс).
* - сохранение разблокированных в localStorage по projectId (закрыл-открыл остались).
*
* API (через game.achievements.*): define/unlock/has/list/progress/bindToStat/
* setButtonVisible/openPage.
*
* Фича-парность: тот же модуль в rublox-player/src/engine/.
*/
const RARITY = {
common: { label: 'Обычное', border: '#9aa3b2', bg: 'linear-gradient(135deg,rgba(120,130,150,0.9),rgba(80,88,104,0.9))', glow: 'rgba(154,163,178,0.5)' },
rare: { label: 'Редкое', border: '#4d8bff', bg: 'linear-gradient(135deg,rgba(60,110,220,0.92),rgba(30,60,150,0.92))', glow: 'rgba(77,139,255,0.6)' },
epic: { label: 'Эпическое', border: '#a05aff', bg: 'linear-gradient(135deg,rgba(150,80,230,0.92),rgba(90,40,160,0.92))', glow: 'rgba(160,90,255,0.65)' },
legendary: { label: 'Легендарное', border: '#ffd23a', bg: 'linear-gradient(135deg,rgba(255,200,60,0.95),rgba(220,140,20,0.95))', glow: 'rgba(255,210,58,0.75)' },
};
export class AchievementsManager {
constructor(scene3d) {
this.s = scene3d;
this._defs = []; // [{id,name,description,icon,rarity,points,hidden}]
this._unlocked = new Set(); // id разблокированных
this._binds = []; // [{id, stat, op, value}]
this._toastQueue = [];
this._toastActive = false;
this._btnVisible = true;
this.btn = null; this.toastRoot = null; this.page = null;
this._projectKey = 'rublox_ach_' + (this.s?._projectId ?? 'proj');
}
define(list) {
const arr = Array.isArray(list) ? list : [list];
for (const a of arr) {
if (!a || typeof a.id !== 'string') continue;
if (this._defs.some(d => d.id === a.id)) continue;
this._defs.push({
id: a.id, name: a.name || a.id, description: a.description || '',
icon: a.icon || '🏆', rarity: RARITY[a.rarity] ? a.rarity : 'common',
points: Number(a.points) || 5, hidden: !!a.hidden,
});
}
this._loadSaved();
this._mountButton();
}
_loadSaved() {
// Резервная локальная копия (мгновенно, до ответа БД).
try {
const raw = localStorage.getItem(this._projectKey);
if (raw) for (const id of JSON.parse(raw)) this._unlocked.add(id);
} catch (e) { /* ignore */ }
}
/** Загрузить разблокированные достижения из БД (по игроку). Вызывать при Play. */
loadFromDB() {
const rt = this.s?.gameRuntime;
if (!rt || !rt.loadProgress) return;
rt.loadProgress('_achievements', (data) => {
if (Array.isArray(data)) {
for (const id of data) this._unlocked.add(id);
}
});
}
_persist() {
// 1) локально (быстрый кэш), 2) в БД (между сессиями, любое устройство).
try { localStorage.setItem(this._projectKey, JSON.stringify([...this._unlocked])); } catch (e) {}
try { this.s?.gameRuntime?.saveProgress?.('_achievements', [...this._unlocked]); } catch (e) {}
}
unlock(id, _playerId) {
const def = this._defs.find(d => d.id === id);
if (!def || this._unlocked.has(id)) return false;
this._unlocked.add(id);
this._persist();
this._queueToast(def);
this._playSound(def.rarity);
return true;
}
has(id) { return this._unlocked.has(id); }
list() {
return this._defs.map(d => ({ id: d.id, name: d.name, unlocked: this._unlocked.has(d.id) }));
}
progress() {
const total = this._defs.length;
const unlocked = this._defs.filter(d => this._unlocked.has(d.id)).length;
const pts = this._defs.filter(d => this._unlocked.has(d.id)).reduce((s, d) => s + d.points, 0);
const maxPts = this._defs.reduce((s, d) => s + d.points, 0);
return { total, unlocked, points: pts, maxPoints: maxPts };
}
/** Авто-unlock при достижении leaderstat значения. */
bindToStat(id, statName, cond) {
const op = cond && (cond.gte != null ? 'gte' : cond.lte != null ? 'lte' : cond.eq != null ? 'eq' : null);
if (!op) return;
this._binds.push({ id, stat: statName, op, value: cond[op] });
// Подпишемся на leaderstats при первом bind.
if (!this._boundLs && this.s?.leaderstats) {
this._boundLs = true;
this.s.leaderstats.onChange((pid, name, nv) => this._checkBinds(name, nv));
}
}
_checkBinds(statName, value) {
for (const b of this._binds) {
if (b.stat !== statName || this._unlocked.has(b.id)) continue;
const ok = b.op === 'gte' ? value >= b.value : b.op === 'lte' ? value <= b.value : value === b.value;
if (ok) this.unlock(b.id);
}
}
setButtonVisible(v) { this._btnVisible = !!v; if (this.btn) this.btn.style.display = v ? 'flex' : 'none'; }
get active() { return this._defs.length > 0; }
// ── Кнопка-кубок ───────────────────────────────────────────────────────
_mountButton() {
if (this.btn || !this.active) return;
if (!this.s?._isPlaying) return; // кнопка-кубок только в Play
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
const b = document.createElement('button');
b.title = 'Мои достижения';
b.textContent = '🏆';
b.style.cssText = [
'position:absolute', 'left:14px', 'bottom:64px', 'z-index:50',
'width:46px', 'height:46px', 'border-radius:12px', 'font-size:24px',
'background:rgba(18,22,33,0.6)', 'backdrop-filter:blur(8px)',
'border:1px solid rgba(255,255,255,0.15)', 'cursor:pointer',
'display:flex', 'align-items:center', 'justify-content:center',
'box-shadow:0 4px 16px rgba(0,0,0,0.35)', 'pointer-events:auto',
].join(';');
if (!this._btnVisible) b.style.display = 'none';
b.onclick = () => this.openPage();
parent.appendChild(b);
this.btn = b;
}
// ── Toast ────────────────────────────────────────────────────────────
_queueToast(def) { this._toastQueue.push(def); if (!this._toastActive) this._nextToast(); }
_nextToast() {
if (!this._toastQueue.length) { this._toastActive = false; return; }
this._toastActive = true;
const def = this._toastQueue.shift();
const r = RARITY[def.rarity];
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
const t = document.createElement('div');
t.style.cssText = [
'position:absolute', 'top:200px', 'right:14px', 'z-index:60',
'width:340px', 'display:flex', 'align-items:center', 'gap:12px',
'padding:12px 14px', 'border-radius:14px', 'background:' + r.bg,
'border:2px solid ' + r.border, 'box-shadow:0 0 24px ' + r.glow + ',0 8px 24px rgba(0,0,0,0.4)',
'font-family:Inter,system-ui,sans-serif', 'color:#fff',
'transform:translateX(380px)', 'transition:transform .32s cubic-bezier(.2,.8,.3,1)',
'pointer-events:auto', 'cursor:pointer',
].join(';');
t.innerHTML =
'<div style="font-size:42px;flex:0 0 auto;filter:drop-shadow(0 2px 4px rgba(0,0,0,0.4))">' + def.icon + '</div>' +
'<div style="flex:1;min-width:0">' +
'<div style="font-size:11px;opacity:0.85;font-weight:700;text-transform:uppercase;letter-spacing:0.5px">Достижение разблокировано · ' + r.label + '</div>' +
'<div style="font-size:17px;font-weight:800;margin:1px 0">' + this._esc(def.name) + '</div>' +
'<div style="font-size:12px;opacity:0.9">' + this._esc(def.description) + ' · +' + def.points + ' очк.</div>' +
'</div>';
t.onclick = () => this.openPage();
parent.appendChild(t);
// slide-in
requestAnimationFrame(() => { t.style.transform = 'translateX(0)'; });
// через 3с slide-out + следующий
setTimeout(() => {
t.style.transform = 'translateX(380px)';
setTimeout(() => { try { t.remove(); } catch (e) {} this._nextToast(); }, 350);
}, 3000);
}
_playSound(rarity) {
// Используем встроенные звуки движка через gameRuntime/audio.
try {
const map = { common: 'coin', rare: 'win', epic: 'win', legendary: 'win' };
const pitch = { common: 1, rare: 1.1, epic: 0.9, legendary: 0.8 }[rarity] || 1;
this.s?.gameRuntime?._playSound?.({ name: map[rarity] || 'coin', pitch });
} catch (e) { /* ignore */ }
}
// ── Страница «Мои достижения» ───────────────────────────────────────────
openPage() {
if (this.page) { this._closePage(); return; }
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
const overlay = document.createElement('div');
overlay.style.cssText = [
'position:absolute', 'inset:0', 'z-index:80',
'background:rgba(8,10,16,0.78)', 'backdrop-filter:blur(6px)',
'display:flex', 'align-items:center', 'justify-content:center',
'font-family:Inter,system-ui,sans-serif', 'pointer-events:auto',
].join(';');
overlay.onclick = (e) => { if (e.target === overlay) this._closePage(); };
const pr = this.progress();
const pct = pr.total ? Math.round(pr.unlocked / pr.total * 100) : 0;
const panel = document.createElement('div');
panel.style.cssText = 'width:min(720px,92%);max-height:84%;overflow-y:auto;background:#141826;border:1px solid rgba(255,255,255,0.12);border-radius:18px;padding:22px;color:#e8ecf2;box-shadow:0 20px 60px rgba(0,0,0,0.5)';
let html = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
'<div style="font-size:22px;font-weight:800">🏆 Мои достижения</div>' +
'<button id="_achClose" style="width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);color:#fff;font-size:18px;cursor:pointer">✕</button></div>';
html += '<div style="font-size:14px;color:#9aa3b2;margin-bottom:6px">' + pr.unlocked + ' из ' + pr.total + ' (' + pr.points + ' / ' + pr.maxPoints + ' очков)</div>';
html += '<div style="height:8px;background:rgba(255,255,255,0.1);border-radius:6px;margin-bottom:18px;overflow:hidden"><div style="height:100%;width:' + pct + '%;background:linear-gradient(90deg,#ffd23a,#ff9a3a);border-radius:6px"></div></div>';
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px">';
for (const d of this._defs) {
const un = this._unlocked.has(d.id);
const r = RARITY[d.rarity];
const hiddenLocked = d.hidden && !un;
const icon = hiddenLocked ? '❔' : d.icon;
const name = hiddenLocked ? 'Скрытое достижение' : d.name;
const desc = hiddenLocked ? 'Найди, чтобы открыть' : d.description;
html += '<div style="background:rgba(255,255,255,0.04);border:2px solid ' + (un ? r.border : 'rgba(255,255,255,0.08)') + ';border-radius:14px;padding:14px 10px;text-align:center;' + (un ? '' : 'opacity:0.55;') + '">' +
'<div style="font-size:44px;margin-bottom:6px;' + (un ? '' : 'filter:grayscale(1);') + '">' + icon + (un ? '' : ' 🔒') + '</div>' +
'<div style="font-size:14px;font-weight:800">' + this._esc(name) + '</div>' +
'<div style="font-size:11px;color:#9aa3b2;margin-top:3px;line-height:1.3">' + this._esc(desc) + '</div>' +
'<div style="font-size:10px;font-weight:700;margin-top:6px;color:' + r.border + '">' + r.label + ' · ' + d.points + ' очк.</div>' +
'</div>';
}
html += '</div>';
panel.innerHTML = html;
overlay.appendChild(panel);
parent.appendChild(overlay);
panel.querySelector('#_achClose').onclick = () => this._closePage();
this.page = overlay;
}
_closePage() { if (this.page) { try { this.page.remove(); } catch (e) {} this.page = null; } }
_esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
serialize() { return this._defs.map(d => ({ ...d })); }
load(arr) { if (Array.isArray(arr) && arr.length) this.define(arr); }
dispose() {
for (const el of [this.btn, this.toastRoot, this.page]) { if (el) try { el.remove(); } catch (e) {} }
this.btn = null; this.page = null; this._toastQueue = []; this._toastActive = false;
}
resetRuntime() {
// Определения и unlocked сохраняются (достижения «навсегда»). Чистим UI.
this._closePage();
this._toastQueue = []; this._toastActive = false;
}
}

View File

@ -72,11 +72,6 @@ import { VehicleHud } from './VehicleHud';
import { ZombieSpawnerManager } from './ZombieSpawnerManager';
import { DynamicsManager } from './DynamicsManager';
import { Environment } from './Environment';
import { SkyboxManager } from './SkyboxManager';
import { LeaderstatsManager } from './LeaderstatsManager';
import { AchievementsManager } from './AchievementsManager';
import { FloaterManager } from './FloaterManager'; // задача 40 — damage floaters
import { InventoryUI } from './InventoryUI'; // задача 44 — drag-drop инвентарь
import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager';
import { GameAudioManager } from './GameAudioManager';
import { AssetManager } from './AssetManager';
@ -1323,11 +1318,6 @@ export class BabylonScene {
}
this.dynamics = new DynamicsManager(this);
this.environment = new Environment(this.scene, this._hemiLight, this._sunLight);
this.skybox = new SkyboxManager(this.scene, this._hemiLight, this._sunLight); // задача 16 — кастомное небо (единый источник света)
this.floaters = new FloaterManager(this); // задача 40 — damage floaters
this.invUI = new InventoryUI(this); // задача 44 — drag-drop инвентарь
this.leaderstats = new LeaderstatsManager(this); // задача 20 — лидерборды
this.achievements = new AchievementsManager(this); // задача 20 — достижения
this.audioManager = new AudioManager();
this.assetManager = new AssetManager();
// PrimitiveManager должен уметь брать dataURL картинки по id ассета,
@ -1431,18 +1421,6 @@ export class BabylonScene {
if (this._isPlaying && this.environment) {
this.environment.tick(dt);
}
// Небо: дрейф облаков + fadeTo
if (this.skybox) {
this.skybox.tick(dt);
}
// Лидерборды (задача 20) — рендер HUD-таблицы при изменениях.
if (this._isPlaying && this.leaderstats) {
this.leaderstats.tick();
}
// Damage floaters (задача 40) — анимация всплывающих цифр.
if (this.floaters) {
this.floaters.tick(dt);
}
// Анимация жидкостей — работает всегда (и в редакторе)
if (this.blockManager) {
this.blockManager.tick(dt);
@ -1740,8 +1718,8 @@ export class BabylonScene {
// peter-panning — тень "уезжала" далеко в сторону от блока (баг
// 2026-05-27). 0.005 — баланс между acne и peter-panning для
// воксельных кубов 1м.
const PCF_BIAS = 0.0008;
const PCF_NORMAL_BIAS = 0.02; // убирает «полосы»-acne на полу от соседних теней
const PCF_BIAS = 0.0005;
const PCF_NORMAL_BIAS = 0.005;
if (!this._shadowGenerator) {
if (wantCsm) {
@ -1751,9 +1729,9 @@ export class BabylonScene {
const csm = new CascadedShadowGenerator(size, this._sunLight);
csm.numCascades = numCascades;
csm.stabilizeCascades = true;
csm.lambda = 0.6;
csm.cascadeBlendPercentage = 0.1;
csm.shadowMaxZ = (q === 'high') ? 90 : 60;
csm.lambda = 0.8;
csm.cascadeBlendPercentage = 0.07;
csm.shadowMaxZ = (q === 'high') ? 200 : 120;
csm.bias = PCF_BIAS;
csm.normalBias = PCF_NORMAL_BIAS;
csm.usePercentageCloserFiltering = true;
@ -1761,8 +1739,7 @@ export class BabylonScene {
? ShadowGenerator.QUALITY_HIGH
: ShadowGenerator.QUALITY_MEDIUM;
csm.darkness = 0.4;
csm.autoCalcDepthBounds = false;
csm.frustumEdgeFalloff = 12; // убирает «полосу-хвост» тени игрока
csm.autoCalcDepthBounds = true;
this._shadowGenerator = csm;
} else {
// Обычный ShadowGenerator. Soft теперь 2048 (было 1024).
@ -2488,18 +2465,6 @@ export class BabylonScene {
const key = this._normalizeKey(e);
this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code });
}
// Задача 44: I — открыть/закрыть инвентарь, Esc — закрыть, 1-9 — хотбар.
if (this._isPlaying && e.code === 'KeyI' && this.invUI &&
(this.invUI.defs.size > 0 || this.invUI.grid.some(Boolean) || this.invUI.hotbar.some(Boolean))) {
e.preventDefault(); this.invUI.toggle(); return;
}
if (this._isPlaying && e.code === 'Escape' && this.invUI?.isOpen()) {
e.preventDefault(); this.invUI.close(); return;
}
if (this._isPlaying && this.invUI && /^Digit[1-9]$/.test(e.code) &&
(this.invUI.hotbar.some(Boolean) || this.invUI.defs.size > 0)) {
this.invUI.setActiveHotbar(parseInt(e.code.slice(5), 10) - 1);
}
if (this._isPlaying && this.placementManager && this.placementManager.isActive()) {
if (e.code === 'KeyR') { e.preventDefault(); this.placementManager.rotate(); return; }
if (e.code === 'Escape') { e.preventDefault(); this.placementManager.cancel(); return; }
@ -5304,11 +5269,6 @@ export class BabylonScene {
}
/** Изменить позицию выделенного (используется Inspector). */
// ── Небо (задача 16) — обёртки для game-API ──────────────────────────
setSkybox(opts) { this.skybox?.setSkybox(opts); }
setClouds(opts) { this.skybox?.setClouds(opts); }
setSkyFog(opts) { this.skybox?.setFog(opts); }
moveSelectedTo(x, y, z) {
if (!this.selection) return;
const sel = this.selection.getSelection();
@ -5509,17 +5469,7 @@ export class BabylonScene {
});
if (this._onPlayerHpChange) this.player.setOnHpChange(this._onPlayerHpChange);
if (this._onPlayerDeath) this.player.setOnDeath(this._onPlayerDeath);
// Точка спавна удалена → игрок появляется в (0, безопасная высота, 0).
let startPoint = this._spawnPoint;
if (this._spawnEnabled === false) {
let sy = 3;
try {
const surf = this.physics?._sampleRobloxSurface?.(0, 0);
if (surf !== null && surf !== undefined) sy = surf + 2;
} catch (e) { /* ignore */ }
startPoint = { x: 0, y: sy, z: 0 };
}
this.player.start(startPoint);
this.player.start(this._spawnPoint);
// Запускаем пользовательские скрипты (этап 2.1).
// Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену,
@ -5669,24 +5619,9 @@ export class BabylonScene {
if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair);
// eslint-disable-next-line no-console
console.log('[BabylonScene] enterPlayMode — scripts to run:', this._scripts?.length || 0, this._scripts);
// Задача 20: смонтировать HUD лидербордов/достижений если определения уже
// загружены из проекта (define из project_data при load).
try { if (this.leaderstats?.active) this.leaderstats._mount(); } catch (e) {}
try { if (this.achievements?.active) this.achievements._mountButton(); } catch (e) {}
try {
if (this.invUI && (this.invUI.defs.size > 0 || this.invUI.hotbar.some(Boolean) || this.invUI.grid.some(Boolean))) {
this.invUI.mountHotbar();
}
} catch (e) {}
// Старт через requestAnimationFrame — даём Babylon собрать сцену
requestAnimationFrame(() => {
if (this._isPlaying) this.gameRuntime?.start(this._scripts || []);
// Задача 20: подгрузить сохранённый прогресс игрока из БД ПОСЛЕ define().
setTimeout(() => {
if (!this._isPlaying) return;
try { this.achievements?.loadFromDB?.(); } catch (e) {}
try { this.leaderstats?.loadFromDB?.(); } catch (e) {}
}, 250);
});
// === Оружие ===
@ -5697,10 +5632,6 @@ export class BabylonScene {
if (hit?.mesh && this.zombieManager) {
this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25);
}
// Урон скриптовым NPC (киты-враги) → авто-floater над мобом (задача 40).
if (hit?.mesh && this.npcManager) {
try { this.npcManager.damageByMesh(hit.mesh, hit.damage || 25); } catch (e) {}
}
if (this._onWeaponHit) {
try { this._onWeaponHit(hit); } catch (e) {}
}
@ -7061,7 +6992,6 @@ export class BabylonScene {
folders: this.folderManager ? this.folderManager.serialize() : [],
gui: this.guiManager ? this.guiManager.serialize() : [],
inventory: this.inventory ? this.inventory.serialize() : null,
inventory2: this.invUI ? this.invUI.serialize() : null, // задача 44
spawnPoint: { ...this._spawnPoint },
playerModelType: this._playerModelType,
skins: this._skinsConfig ? {
@ -7078,9 +7008,6 @@ export class BabylonScene {
crosshair: this._crosshair || 'dot',
shadowQuality: this._shadowQuality || 'soft',
environment: this.environment ? this.environment.serialize() : null,
skybox: this.skybox ? this.skybox.serialize() : null,
leaderstats: this.leaderstats ? this.leaderstats.serialize() : null,
achievements: this.achievements ? this.achievements.serialize() : null,
audio: this.audioManager ? this.audioManager.serialize() : null,
// Библиотека пользовательских картинок (текстуры/GUI-image).
assets: this.assetManager ? this.assetManager.serialize() : [],
@ -7478,9 +7405,6 @@ export class BabylonScene {
if (this.inventory) {
this.inventory.loadFromArray(state.scene.inventory || null);
}
if (this.invUI && state.scene.inventory2) { // задача 44
this.invUI.load(state.scene.inventory2);
}
// Простановка folderId на блоках (loadFromArray BlockManager не знает про это поле)
if (this.blockManager && Array.isArray(state.scene.blocks)) {
for (const b of state.scene.blocks) {
@ -7524,10 +7448,8 @@ export class BabylonScene {
// Точка спавна
if (state.scene.spawnPoint) {
this._spawnPoint = { ...state.scene.spawnPoint };
this._updateSpawnMarker?.();
this._updateSpawnMarker();
}
// Удалена ли точка спавна (плеер: спавн в 0,0 при отсутствии).
this._spawnEnabled = state.scene.spawnEnabled !== false;
// === Авто-fix спавна для smooth terrain ===
// Если есть RobloxTerrain и spawnPoint оказался НИЖЕ поверхности —
// поднимаем его, чтобы игрок не провалился в момент нажатия "Запустить".
@ -7565,17 +7487,6 @@ export class BabylonScene {
if (state.scene.environment && this.environment) {
this.environment.load(state.scene.environment);
}
// Кастомное небо (задача 16)
if (state.scene.skybox && this.skybox) {
this.skybox.load(state.scene.skybox);
}
// Лидерборды и достижения (задача 20) — определения из проекта.
if (state.scene.leaderstats && this.leaderstats) {
this.leaderstats.load(state.scene.leaderstats);
}
if (state.scene.achievements && this.achievements) {
this.achievements.load(state.scene.achievements);
}
// Аудио (фоновая музыка/амбиент)
if (state.scene.audio && this.audioManager) {
this.audioManager.load(state.scene.audio);
@ -7642,11 +7553,6 @@ export class BabylonScene {
this._isPlaying = false;
// Задача 04: закрываем любой активный модал чтобы не «висел» при стопе
try { this.modalManager?._instantClose?.(); } catch (e) {}
// Задача 20: чистим рантайм лидербордов/достижений (определения остаются).
try { this.leaderstats?.resetRuntime?.(); } catch (e) {}
try { this.achievements?.resetRuntime?.(); } catch (e) {}
try { this.floaters?.resetRuntime?.(); } catch (e) {} // задача 40
try { this.invUI?.resetRuntime?.(); } catch (e) {} // задача 44
// Сбрасываем таймер прохождения
this._timerRunning = false;
this._timerStartedAt = null;

View File

@ -91,14 +91,10 @@ export class Environment {
this.fogEnabled = false;
this.fogColor = [0.7, 0.8, 0.9];
this.fogDensity = 0.01;
// Видимые тела на небе (солнце и луна).
// ВАЖНО (задача 16): единое небо рисует SkyboxManager. Environment больше
// НЕ рисует свою жёлтую сферу/луну — иначе на небе два солнца. Здесь
// остаётся только управление светом (направление/яркость/ambient).
this._drawSkyBodies = false;
// Видимые тела на небе (солнце и луна) — создаём по запросу
this._sunMesh = null;
this._moonMesh = null;
if (this._drawSkyBodies) this._createSkyBodies();
this._createSkyBodies();
this._applyTime();
}

View File

@ -1,236 +0,0 @@
/**
* FloaterManager всплывающие цифры урона (Damage Floaters), задача 40.
*
* game.fx.damageFloater(position, value, opts) над точкой всплывает число,
* поднимается вверх, покачивается, плавно гаснет. Цвета: damage/crit/heal/
* mana/miss. Object pool из переиспользуемых billboard-планов (без create/
* destroy на каждый удар). Стек одинаковых по stackKey («×N»). Комикс-стиль
* (BAM!/KAPOW!/POW!).
*
* Билборд = плоскость с DynamicTexture (как LabelManager), billboardMode=7,
* renderingGroupId=1 (всегда поверх геометрии), disableDepthWrite.
*
* Фича-парность: тот же модуль в rublox-player/src/engine/.
*/
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
import { Color3 } from '@babylonjs/core/Maths/math.color';
import { Mesh } from '@babylonjs/core/Meshes/mesh';
const POOL_SIZE = 30;
const TEX_W = 512, TEX_H = 256;
// Пресеты типов урона: цвет текста + множители.
const PRESETS = {
damage: { color: '#ff5a4a', stroke: '#3a0000' },
crit: { color: '#ffd23a', stroke: '#5a3a00' },
heal: { color: '#46e06a', stroke: '#063a14' },
mana: { color: '#4aa8ff', stroke: '#001a3a' },
miss: { color: '#b8b8b8', stroke: '#222222' },
};
function easeOutQuad(t) { return 1 - (1 - t) * (1 - t); }
export class FloaterManager {
constructor(scene3d) {
this.s = scene3d;
this.scene = scene3d.scene;
this.pool = [];
this._initialized = false;
this._stacks = new Map(); // stackKey → slot (для накопления ×N)
}
_init() {
if (this._initialized) return;
this._initialized = true;
for (let i = 0; i < POOL_SIZE; i++) {
const tex = new DynamicTexture(`floaterTex_${i}`, { width: TEX_W, height: TEX_H }, this.scene, true);
tex.hasAlpha = true;
const plane = MeshBuilder.CreatePlane(`floater_${i}`, { width: 2.4, height: 1.2, sideOrientation: Mesh.DOUBLESIDE }, this.scene);
const mat = new StandardMaterial(`floaterMat_${i}`, this.scene);
mat.diffuseTexture = tex;
mat.diffuseTexture.hasAlpha = true;
mat.emissiveColor = new Color3(1, 1, 1);
mat.diffuseColor = new Color3(0, 0, 0);
mat.disableLighting = true;
mat.backFaceCulling = false;
mat.disableDepthWrite = true;
mat.useAlphaFromDiffuseTexture = true;
plane.material = mat;
plane.billboardMode = 7;
plane.renderingGroupId = 1;
plane.isPickable = false;
plane.setEnabled(false);
this.pool.push({ plane, tex, mat, active: false, age: 0, lifetime: 0.8 });
}
}
_acquire() {
for (const slot of this.pool) if (!slot.active) return slot;
return null; // все заняты — пропускаем новый floater (норма)
}
/**
* Главный API. position: {x,y,z}; value: число|строка; opts см. задачу 40.
*/
spawn(position, value, opts = {}) {
this._init();
if (!position) return;
opts = opts || {};
// Стек: одинаковый stackKey за время жизни накапливает счётчик.
if (opts.stackKey && this._stacks.has(opts.stackKey)) {
const slot = this._stacks.get(opts.stackKey);
if (slot.active) {
slot.stackCount = (slot.stackCount || 1) + 1;
slot.age = Math.min(slot.age, slot.lifetime * 0.3); // продлеваем
this._draw(slot, slot.baseText, slot.preset, slot.fontSize, slot.comic, slot.stackCount);
return;
}
}
const slot = this._acquire();
if (!slot) return;
// Тип floater'а.
let kind = 'damage';
if (opts.isCrit) kind = 'crit';
else if (opts.isHeal) kind = 'heal';
else if (opts.isMana) kind = 'mana';
else if (opts.isMiss) kind = 'miss';
const preset = PRESETS[kind];
const color = opts.color || preset.color;
const stroke = opts.strokeColor || preset.stroke;
let fontSize = Number.isFinite(opts.fontSize) ? opts.fontSize : 60;
let floatHeight = Number.isFinite(opts.floatHeight) ? opts.floatHeight : 2;
let lifetime = Number.isFinite(opts.lifetime) ? opts.lifetime : 0.9;
const randomOffset = Number.isFinite(opts.randomOffset) ? opts.randomOffset : (opts.isCrit ? 0.5 : 0.25);
// Текст: число с минусом (урон) или как есть (строка / heal с плюсом).
let baseText;
if (typeof value === 'string') baseText = value;
else if (opts.isHeal) baseText = '+' + value;
else if (opts.isMiss) baseText = String(value);
else baseText = '-' + Math.abs(value);
if (opts.isCrit) { fontSize = Math.round(fontSize * 1.4); floatHeight *= 1.2; }
slot.active = true;
slot.age = 0;
slot.lifetime = lifetime;
slot.floatHeight = floatHeight;
slot.isCrit = !!opts.isCrit;
slot.color = color; slot.stroke = stroke;
slot.preset = { color, stroke };
slot.fontSize = fontSize;
slot.comic = !!opts.comicStyle;
slot.baseText = baseText;
slot.stackCount = 1;
slot.stackKey = opts.stackKey || null;
const rx = (Math.random() - 0.5) * 2 * randomOffset;
const rz = (Math.random() - 0.5) * 2 * randomOffset;
slot.startX = position.x + rx;
slot.startY = position.y + (Number.isFinite(opts.yOffset) ? opts.yOffset : 1.5);
slot.startZ = position.z + rz;
slot.plane.position.set(slot.startX, slot.startY, slot.startZ);
slot.plane.scaling.set(1, 1, 1);
slot.plane.setEnabled(true);
this._draw(slot, baseText, slot.preset, fontSize, slot.comic, 1);
if (opts.stackKey) this._stacks.set(opts.stackKey, slot);
}
_draw(slot, baseText, preset, fontSize, comic, stackCount) {
const ctx = slot.tex.getContext();
ctx.clearRect(0, 0, TEX_W, TEX_H);
let text = baseText;
if (comic) {
const num = parseInt(String(baseText).replace(/[^0-9]/g, ''), 10) || 0;
if (slot.isCrit) text = 'POW!';
else if (num > 100) text = 'KAPOW!';
else if (num > 50) text = 'BAM!';
}
if (stackCount > 1) text = baseText + ' ×' + stackCount;
const fs = comic ? Math.round(fontSize * 1.1) : fontSize;
ctx.font = `900 ${fs}px ${comic ? 'Bangers, Impact, sans-serif' : 'Inter, Arial, sans-serif'}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineJoin = 'round';
// Комикс-фон: жёлтая звезда-вспышка.
if (comic) {
ctx.save();
ctx.translate(TEX_W / 2, TEX_H / 2);
ctx.fillStyle = 'rgba(255,210,60,0.9)';
ctx.beginPath();
const spikes = 10, outer = 130, inner = 70;
for (let i = 0; i < spikes * 2; i++) {
const r = i % 2 === 0 ? outer : inner;
const a = (i / (spikes * 2)) * Math.PI * 2 - Math.PI / 2;
const px = Math.cos(a) * r, py = Math.sin(a) * r * 0.55;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.closePath(); ctx.fill();
ctx.restore();
}
// Обводка + текст.
ctx.strokeStyle = comic ? '#000' : preset.stroke;
ctx.lineWidth = Math.max(6, fs * 0.16);
ctx.strokeText(text, TEX_W / 2, TEX_H / 2);
ctx.fillStyle = comic ? '#d22' : preset.color;
ctx.fillText(text, TEX_W / 2, TEX_H / 2);
slot.tex.update(true);
}
/** Вызывать каждый кадр (анимация подъёма + fade + покачивание + crit-pop). */
tick(dt) {
if (!this._initialized) return;
for (const slot of this.pool) {
if (!slot.active) continue;
slot.age += dt;
const t = slot.age / slot.lifetime;
if (t >= 1) {
slot.active = false;
slot.plane.setEnabled(false);
if (slot.stackKey && this._stacks.get(slot.stackKey) === slot) this._stacks.delete(slot.stackKey);
continue;
}
const ease = easeOutQuad(t);
slot.plane.position.y = slot.startY + slot.floatHeight * ease;
slot.plane.position.x = slot.startX + Math.sin(slot.age * 5) * 0.12;
// fade-in 0.12 / hold / fade-out 0.25
let alpha = 1;
if (t < 0.12) alpha = t / 0.12;
else if (t > 0.75) alpha = 1 - (t - 0.75) / 0.25;
slot.mat.alpha = Math.max(0, Math.min(1, alpha));
// crit pop: scale 1 → 1.3 → 1 в первые 0.4 жизни
if (slot.isCrit) {
let s = 1;
if (t < 0.2) s = 1 + (t / 0.2) * 0.3;
else if (t < 0.4) s = 1.3 - ((t - 0.2) / 0.2) * 0.3;
slot.plane.scaling.set(s, s, s);
}
}
}
dispose() {
for (const slot of this.pool) {
try { slot.plane.dispose(); slot.tex.dispose(); slot.mat.dispose(); } catch (e) {}
}
this.pool = []; this._stacks.clear(); this._initialized = false;
}
resetRuntime() {
for (const slot of this.pool) { slot.active = false; slot.plane?.setEnabled(false); }
this._stacks.clear();
}
}

View File

@ -17,7 +17,6 @@
import { Color3 } from '@babylonjs/core';
import { ScriptSandbox } from './ScriptSandbox';
import { STORYS_addres } from '../api/API';
import { LabelManager } from './LabelManager'; // задача: scene.setLabel (require крашит в браузере)
export class GameRuntime {
constructor(scene3d) {
@ -69,20 +68,6 @@ export class GameRuntime {
this._isRunning = true;
// eslint-disable-next-line no-console
console.log('[GameRuntime] start called with scripts:', scripts);
// Задача 20: мост leaderstats.onChange (main) → globalEvent в worker'ы.
try {
const ls = this.scene3d?.leaderstats;
if (ls && !ls._bridgeBound) {
ls._bridgeBound = true;
const meId = ls._resolveMe?.();
ls.onChange((pid, name, nv, ov) => {
for (const sb of this.sandboxes) sb.sendGlobalEvent({
type: 'leaderstatsChange', playerId: pid, name, newValue: nv, oldValue: ov,
isMe: String(pid) === String(meId),
});
});
}
} catch (e) { /* ignore */ }
if (!Array.isArray(scripts) || scripts.length === 0) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] start: no scripts to run');
@ -1627,11 +1612,6 @@ export class GameRuntime {
});
return;
}
if (cmd === 'npc.setAttacking') {
this._npcCmd(payload?.ref, (nid) =>
this.scene3d?.npcManager?.setAttacking?.(nid, !!payload?.on));
return;
}
if (cmd === 'npc.stop') {
this._npcCmd(payload?.ref, (nid) =>
this.scene3d?.npcManager?.stopNpc(nid));
@ -1754,34 +1734,6 @@ export class GameRuntime {
if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; }
if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; }
// === Damage Floaters (задача 40) ===
if (cmd === 'fx.damageFloater') {
try {
let pos = payload?.position;
if (typeof pos === 'string') {
if (pos === 'player') {
const pl = this.scene3d?.player;
const p = pl ? (pl._pos || pl.position || pl.mesh?.position) : null;
pos = p ? { x: p.x, y: p.y, z: p.z } : null;
} else {
const tgt = this._resolveTweenTarget(pos);
pos = tgt ? { x: tgt.data.x || 0, y: tgt.data.y || 0, z: tgt.data.z || 0 } : null;
}
}
if (pos) this.scene3d?.floaters?.spawn(pos, payload?.value, payload?.opts || {});
} catch (e) { /* ignore */ }
return;
}
if (cmd === 'fx.autoMobFloaters') {
try {
if (this.scene3d?.npcManager) {
this.scene3d.npcManager._autoFloater = payload?.enabled
? { opts: payload?.opts || {} } : null;
}
} catch (e) { /* ignore */ }
return;
}
if (cmd === 'fx.create') {
// payload: { kind: 'beam'|'trail', localRef, ... }
const bm = this.scene3d?.beamManager;
@ -1944,16 +1896,6 @@ export class GameRuntime {
}
return;
}
// === Задача 44: drag-drop инвентарь (invUI) ===
if (cmd === 'items.define') { try { this.scene3d?.invUI?.defineItem(payload.def); } catch (e) {} return; }
if (cmd === 'inv2.add') { try { this.scene3d?.invUI?.add(payload.itemId, payload.count); this.scene3d?.invUI?.mountHotbar(); } catch (e) {} return; }
if (cmd === 'inv2.remove') { try { this.scene3d?.invUI?.remove(payload.itemId, payload.count); } catch (e) {} return; }
if (cmd === 'inv2.open') { try { this.scene3d?.invUI?.open(); } catch (e) {} return; }
if (cmd === 'inv2.close') { try { this.scene3d?.invUI?.close(); } catch (e) {} return; }
if (cmd === 'inv2.toggle') { try { this.scene3d?.invUI?.toggle(); } catch (e) {} return; }
if (cmd === 'inv2.sort') { try { this.scene3d?.invUI?.sort(payload.by); } catch (e) {} return; }
if (cmd === 'inv2.setActive') { try { this.scene3d?.invUI?.setActiveHotbar(payload.i); } catch (e) {} return; }
if (cmd === 'inventory.remove') {
// payload: { modelTypeId? , name? } — убрать первый совпавший слот.
const inv = this.scene3d?.inventory;
@ -2593,83 +2535,12 @@ export class GameRuntime {
}
return;
}
// === Небо и атмосфера (задача 16) ===
// === Лидерборды и достижения (задача 20) ===
if (cmd === 'leaderstats.define') {
try { this.scene3d?.leaderstats?.define(payload.name, payload.opts || {}); } catch (e) {}
return;
}
if (cmd === 'leaderstats.set') {
try { this.scene3d?.leaderstats?.set(payload.playerId, payload.name, payload.value); } catch (e) {}
return;
}
if (cmd === 'leaderstats.add') {
try { this.scene3d?.leaderstats?.add(payload.playerId, payload.name, payload.delta); } catch (e) {}
return;
}
if (cmd === 'achievements.define') {
try { this.scene3d?.achievements?.define(payload.list); } catch (e) {}
return;
}
if (cmd === 'achievements.unlock') {
try { this.scene3d?.achievements?.unlock(payload.id, payload.playerId); } catch (e) {}
return;
}
if (cmd === 'achievements.bindToStat') {
try { this.scene3d?.achievements?.bindToStat(payload.id, payload.statName, payload.cond || {}); } catch (e) {}
return;
}
if (cmd === 'achievements.setButtonVisible') {
try { this.scene3d?.achievements?.setButtonVisible(!!payload.visible); } catch (e) {}
return;
}
if (cmd === 'achievements.openPage') {
try { this.scene3d?.achievements?.openPage(); } catch (e) {}
return;
}
if (cmd === 'scene.setSkybox') {
try { this.scene3d?.skybox?.setSkybox(payload?.opts || {}); } catch (e) {}
return;
}
if (cmd === 'scene.setClouds') {
try { this.scene3d?.skybox?.setClouds(payload?.opts || {}); } catch (e) {}
return;
}
if (cmd === 'scene.setFog') {
try { this.scene3d?.skybox?.setFog(payload?.opts || {}); } catch (e) {}
return;
}
if (cmd === 'scene.skyboxFadeTo') {
try { this.scene3d?.skybox?.fadeTo(payload?.opts || {}, payload?.duration || 2); } catch (e) {}
return;
}
if (cmd === 'scene.skyboxSunDir') {
try { this.scene3d?.skybox?.setSunDirection(payload?.dir || {}); } catch (e) {}
return;
}
if (cmd === 'scene.setScale') {
try {
const k = Number(payload?.scale);
if (!Number.isFinite(k) || k < 0) return;
const pm = this.scene3d?.primitiveManager;
const rid = this._resolvePrimitiveId(payload?.id ?? payload?.ref);
const data = (pm && rid != null) ? pm.instances.get(rid) : null;
if (data?.mesh) {
if (data._worldMatrixFrozen) { try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} data._worldMatrixFrozen = false; }
data.mesh.scaling.set(k, k, k);
}
} catch (e) {}
return;
}
if (cmd === 'scene.setColor') {
try {
const color = payload?.color;
if (typeof color !== 'string') return;
// Окрашиваемый блок (studs-block): ref 'block:x,y,z' → BlockManager.
const ref = payload?.id ?? payload?.ref;
const ref = payload?.id;
if (typeof ref === 'string' && ref.startsWith('block:')) {
const parts = ref.slice(6).split(',').map(Number);
if (parts.length === 3 && parts.every(Number.isFinite)) {
@ -2679,7 +2550,7 @@ export class GameRuntime {
}
const pm = this.scene3d?.primitiveManager;
if (!pm) return;
const rid = this._resolvePrimitiveId(payload?.id ?? payload?.ref);
const rid = this._resolvePrimitiveId(payload?.id);
const data = rid != null ? pm.instances.get(rid) : null;
if (data) {
data.color = color;
@ -2806,6 +2677,7 @@ export class GameRuntime {
if (typeof ref !== 'string') return;
// ленивое создание менеджера меток
if (!this.scene3d._labelManager) {
const { LabelManager } = require('./LabelManager');
this.scene3d._labelManager = new LabelManager(this.scene3d.scene);
}
const lm = this.scene3d._labelManager;
@ -3024,12 +2896,8 @@ export class GameRuntime {
}
if (cmd === 'scene.setVisible') {
try {
let kind = payload?.kind;
let id = payload?.id;
if ((kind == null || id == null) && typeof payload?.ref === 'string') {
const colon = payload.ref.indexOf(':');
if (colon > 0) { kind = payload.ref.slice(0, colon); id = payload.ref.slice(colon + 1); }
}
const kind = payload?.kind;
const id = payload?.id;
const visible = !!payload?.visible;
if (id == null) return;
if (kind === 'primitive') {
@ -3884,11 +3752,6 @@ export class GameRuntime {
const id = t.id ?? t.ref;
this.scene3d?.primitiveManager?.removeInstance(id);
}
// Снять interact-подсказку удалённого объекта (иначе «E» висит на пустоте).
if (t.kind && (t.ref ?? t.id) != null && Array.isArray(this._interactables)) {
const ref = t.kind + ':' + (t.ref ?? t.id);
this._interactables = this._interactables.filter(it => it.ref !== ref);
}
this.scheduleSceneSnapshot();
} catch (e) {
// eslint-disable-next-line no-console
@ -4127,27 +3990,6 @@ export class GameRuntime {
} catch (e) {}
return h;
}
/** Задача 20: сохранить прогресс игрока в БД (для leaderstats/achievements). */
saveProgress(namespace, data) {
const url = this._saveBaseUrl(namespace);
if (!url) return;
try {
fetch(url, {
method: 'POST', headers: this._saveAuthHeaders(),
body: JSON.stringify({ data }),
}).catch(() => {});
} catch (e) { /* ignore */ }
}
/** Задача 20: загрузить прогресс из БД (cb(data|null)). */
loadProgress(namespace, cb) {
const url = this._saveBaseUrl(namespace);
if (!url) { cb && cb(null); return; }
fetch(url, { headers: this._saveAuthHeaders() })
.then(r => r.json())
.then(j => cb && cb(j.data ?? null))
.catch(() => cb && cb(null));
}
_saveSet(payload) {
const url = this._saveBaseUrl(payload?.namespace);
if (!url) return;

View File

@ -1,370 +0,0 @@
/**
* InventoryUI drag-drop инвентарь (задача 44): сетка 8×5 + hotbar 9 + стаки +
* редкости + ПКМ-меню + tooltip. Самодостаточный DOM-оверлей (как
* LoadingScreenOverlay) крепится к canvas.parentElement, работает в студии и
* плеере одинаково.
*
* Хранит: item-defs (game.items.define), слоты основного инвентаря (GRID),
* слоты hotbar (HOTBAR), активный hotbar-слот. Постоянный hotbar внизу HUD;
* окно инвентаря по клавише I (toggle).
*
* API (через game.inventory.* / game.items.*):
* game.items.define({id,name,icon,rarity,maxStack,description,value,onUse,tags})
* game.inventory.add(itemId, count) / remove / has / count
* game.inventory.open() / close() / toggle() / isOpen()
* game.inventory.move(from, to) / split(slot, n) / sort(by) / use(slot)
* game.inventory.setActiveHotbar(i) / getActiveItem()
*
* Фича-парность: тот же модуль в rublox-player/src/engine/.
*/
const GRID = 40; // 8×5 основной инвентарь
const COLS = 8;
const HOTBAR = 9;
const RARITY = {
common: { color: '#bbbbbb', label: 'Обычное' },
uncommon: { color: '#5cb85c', label: 'Необычное' },
rare: { color: '#5bc0de', label: 'Редкое' },
epic: { color: '#9b59b6', label: 'Эпическое' },
legendary: { color: '#f0ad4e', label: 'Легендарное' },
};
export class InventoryUI {
constructor(scene3d) {
this.s = scene3d;
this.defs = new Map(); // itemId → def
this.grid = new Array(GRID).fill(null); // {itemId,count}|null
this.hotbar = new Array(HOTBAR).fill(null);
this.active = 0;
this._open = false;
this.root = null; this.hotbarRoot = null; this.tooltip = null; this.ctxMenu = null;
this._drag = null; // {from:'grid'|'hotbar', idx}
this._onChange = [];
this._events = { added: [], removed: [], used: [], slot: [] };
this._opts = { allowDrop: true, allowSplit: true, showRarity: true };
}
// ── Определения предметов ───────────────────────────────────────────────
defineItem(def) {
if (!def || typeof def.id !== 'string') return;
this.defs.set(def.id, {
id: def.id, name: def.name || def.id,
icon: def.icon || null, emoji: def.emoji || null,
rarity: RARITY[def.rarity] ? def.rarity : 'common',
maxStack: Number(def.maxStack) > 0 ? Number(def.maxStack) : 1,
description: def.description || '', value: Number(def.value) || 0,
tags: Array.isArray(def.tags) ? def.tags : [],
onUseEffect: def.onUseEffect || null, // 'heal:50' | 'speed:1.5:5' | null
});
}
_def(id) { return this.defs.get(id) || { id, name: id, rarity: 'common', maxStack: 99, emoji: '📦', icon: null, description: '', value: 0, tags: [] }; }
// ── Операции ────────────────────────────────────────────────────────────
add(itemId, count = 1) {
const def = this._def(itemId);
let left = count;
// 1) долить в существующие стаки (сначала hotbar — он на виду, потом grid)
const fill = (arr) => {
for (let i = 0; i < arr.length && left > 0; i++) {
const s = arr[i];
if (s && s.itemId === itemId && s.count < def.maxStack) {
const room = def.maxStack - s.count;
const take = Math.min(room, left);
s.count += take; left -= take;
}
}
};
fill(this.hotbar); fill(this.grid);
// 2) в пустые слоты (сначала hotbar — собранное видно сразу, потом grid)
const place = (arr) => {
for (let i = 0; i < arr.length && left > 0; i++) {
if (!arr[i]) { const take = Math.min(def.maxStack, left); arr[i] = { itemId, count: take }; left -= take; }
}
};
place(this.hotbar); place(this.grid);
const added = count - left;
if (added > 0) { this._emit('added', { itemId, count: added }); this._changed(); }
return { added, overflow: left };
}
remove(itemId, count = 1) {
let left = count;
const drain = (arr) => {
for (let i = arr.length - 1; i >= 0 && left > 0; i--) {
const s = arr[i];
if (s && s.itemId === itemId) {
const take = Math.min(s.count, left);
s.count -= take; left -= take;
if (s.count <= 0) arr[i] = null;
}
}
};
drain(this.hotbar); drain(this.grid);
const removed = count - left;
if (removed > 0) { this._emit('removed', { itemId, count: removed }); this._changed(); }
return removed;
}
count(itemId) {
let n = 0;
for (const s of this.grid) if (s && s.itemId === itemId) n += s.count;
for (const s of this.hotbar) if (s && s.itemId === itemId) n += s.count;
return n;
}
has(itemId, n = 1) { return this.count(itemId) >= n; }
/** slot-ref: число 0..39 = grid; строка 'h0'..'h8' = hotbar. */
_arrIdx(ref) {
if (typeof ref === 'string' && ref[0] === 'h') return { arr: this.hotbar, idx: parseInt(ref.slice(1), 10) };
return { arr: this.grid, idx: Number(ref) };
}
move(from, to) {
const a = this._arrIdx(from), b = this._arrIdx(to);
if (!a.arr || !b.arr || a.idx == null || b.idx == null) return;
if (a.arr === b.arr && a.idx === b.idx) return;
const src = a.arr[a.idx], dst = b.arr[b.idx];
// merge одинаковых стаков
if (src && dst && src.itemId === dst.itemId) {
const def = this._def(src.itemId);
const room = def.maxStack - dst.count;
if (room > 0) {
const take = Math.min(room, src.count);
dst.count += take; src.count -= take;
if (src.count <= 0) a.arr[a.idx] = null;
this._changed(); return;
}
}
// swap
a.arr[a.idx] = dst; b.arr[b.idx] = src;
this._changed();
}
split(ref, n) {
if (!this._opts.allowSplit) return;
const { arr, idx } = this._arrIdx(ref);
const s = arr[idx]; if (!s || s.count <= 1) return;
const take = Math.max(1, Math.min(s.count - 1, n || Math.floor(s.count / 2)));
const empty = this.grid.indexOf(null);
if (empty < 0) return;
s.count -= take; this.grid[empty] = { itemId: s.itemId, count: take };
this._changed();
}
sort(by = 'rarity') {
const order = { legendary: 0, epic: 1, rare: 2, uncommon: 3, common: 4 };
const all = this.grid.filter(Boolean);
all.sort((x, y) => {
const dx = this._def(x.itemId), dy = this._def(y.itemId);
if (by === 'rarity') return (order[dx.rarity] - order[dy.rarity]) || dx.name.localeCompare(dy.name);
if (by === 'name') return dx.name.localeCompare(dy.name);
return dx.id.localeCompare(dy.id);
});
this.grid = all.concat(new Array(GRID - all.length).fill(null));
this._changed();
}
use(ref) {
const { arr, idx } = this._arrIdx(ref);
const s = arr[idx]; if (!s) return;
const def = this._def(s.itemId);
let consume = false;
if (def.onUseEffect) {
const [eff, a, b] = String(def.onUseEffect).split(':');
try {
if (eff === 'heal') { this.s?.player?.heal?.(Number(a) || 25); consume = true; }
else if (eff === 'speed') { this.s?.player?.setSpeed?.(Number(a) || 1.5); consume = true; }
} catch (e) { /* ignore */ }
}
this._emit('used', { itemId: s.itemId });
if (consume) { s.count -= 1; if (s.count <= 0) arr[idx] = null; this._changed(); }
}
setActiveHotbar(i) { this.active = Math.max(0, Math.min(HOTBAR - 1, i | 0)); this._renderHotbar(); }
getActiveItem() { const s = this.hotbar[this.active]; return s ? { ...s, def: this._def(s.itemId) } : null; }
onChange(fn) { if (typeof fn === 'function') this._onChange.push(fn); }
on(evt, fn) { if (this._events[evt] && typeof fn === 'function') this._events[evt].push(fn); }
_emit(evt, data) { for (const fn of (this._events[evt] || [])) { try { fn(data); } catch (e) {} } }
_changed() {
for (const fn of this._onChange) { try { fn(); } catch (e) {} }
this._emit('slot', {});
if (this._open) this._renderGrid();
this._renderHotbar();
}
// ── DOM: hotbar (постоянный) ───────────────────────────────────────────
_parent() { return (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body; }
mountHotbar() {
if (this.hotbarRoot) return;
const r = document.createElement('div');
r.style.cssText = 'position:absolute;left:50%;bottom:64px;transform:translateX(-50%);z-index:48;display:flex;gap:6px;pointer-events:auto;font-family:Inter,system-ui,sans-serif';
this._parent().appendChild(r); this.hotbarRoot = r;
this._renderHotbar();
}
_slotInner(s) {
if (!s) return '';
const def = this._def(s.itemId);
const icon = def.icon ? `<img src="${def.icon}" style="width:80%;height:80%;object-fit:contain;pointer-events:none">`
: `<span style="font-size:26px;pointer-events:none">${def.emoji || '📦'}</span>`;
const cnt = s.count > 1 ? `<span style="position:absolute;right:3px;bottom:1px;font-size:13px;font-weight:900;color:#fff;text-shadow:0 1px 2px #000">${s.count}</span>` : '';
return icon + cnt;
}
_slotStyle(s, activeBorder) {
const rc = (s && this._opts.showRarity) ? RARITY[this._def(s.itemId).rarity].color : 'rgba(255,255,255,0.15)';
const border = activeBorder ? '#ffd23a' : rc;
return `position:relative;width:52px;height:52px;border-radius:10px;border:2px solid ${border};background:rgba(20,26,40,0.7);display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.3)` + (activeBorder ? ';box-shadow:0 0 12px #ffd23a' : '');
}
_renderHotbar() {
if (!this.hotbarRoot) return;
this.hotbarRoot.innerHTML = '';
for (let i = 0; i < HOTBAR; i++) {
const s = this.hotbar[i];
const cell = document.createElement('div');
cell.style.cssText = this._slotStyle(s, i === this.active);
cell.innerHTML = `<span style="position:absolute;left:3px;top:1px;font-size:11px;color:#9aa3b2;font-weight:700">${i + 1}</span>` + this._slotInner(s);
cell.onmouseenter = (e) => this._showTooltip(s, e);
cell.onmouseleave = () => this._hideTooltip();
cell.onclick = () => { this.setActiveHotbar(i); };
cell.oncontextmenu = (e) => { e.preventDefault(); this._openCtx('h' + i, e); };
this._wireDrag(cell, 'h' + i);
this.hotbarRoot.appendChild(cell);
}
}
// ── DOM: окно инвентаря ─────────────────────────────────────────────────
open() { if (this._open) return; this._open = true; this._mountWindow(); }
close() { this._open = false; if (this.root) { try { this.root.remove(); } catch (e) {} this.root = null; } this._hideTooltip(); this._closeCtx(); }
toggle() { this._open ? this.close() : this.open(); }
isOpen() { return this._open; }
_mountWindow() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:absolute;inset:0;z-index:70;background:rgba(8,10,16,0.6);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;font-family:Inter,system-ui,sans-serif;pointer-events:auto';
overlay.onclick = (e) => { if (e.target === overlay) this.close(); };
const panel = document.createElement('div');
panel.style.cssText = 'width:min(640px,94%);background:#141826;border:1px solid rgba(255,255,255,0.12);border-radius:18px;padding:20px;color:#e8ecf2;box-shadow:0 20px 60px rgba(0,0,0,0.5)';
panel.onclick = (e) => e.stopPropagation();
panel.innerHTML =
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px">' +
'<div style="font-size:22px;font-weight:800">🎒 Инвентарь</div>' +
'<div style="display:flex;gap:8px">' +
'<button id="_inv_sort" style="height:34px;padding:0 14px;border-radius:9px;background:#2a3550;border:1px solid rgba(255,255,255,0.15);color:#fff;cursor:pointer;font-weight:700">Сорт.</button>' +
'<button id="_inv_close" style="width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);color:#fff;font-size:18px;cursor:pointer">✕</button>' +
'</div></div>' +
'<div id="_inv_grid" style="display:grid;grid-template-columns:repeat(' + COLS + ',1fr);gap:6px"></div>' +
'<div style="margin:16px 0 6px;font-size:13px;color:#9aa3b2;font-weight:700">Панель быстрого доступа (1-9)</div>' +
'<div id="_inv_hb" style="display:grid;grid-template-columns:repeat(' + HOTBAR + ',1fr);gap:6px"></div>';
overlay.appendChild(panel); this._parent().appendChild(overlay); this.root = overlay;
panel.querySelector('#_inv_close').onclick = () => this.close();
panel.querySelector('#_inv_sort').onclick = () => this.sort('rarity');
this._gridEl = panel.querySelector('#_inv_grid');
this._hbEl = panel.querySelector('#_inv_hb');
this._renderGrid();
}
_renderGrid() {
if (!this._gridEl) return;
const build = (el, arr, prefix) => {
el.innerHTML = '';
for (let i = 0; i < arr.length; i++) {
const ref = prefix === 'h' ? 'h' + i : i;
const s = arr[i];
const cell = document.createElement('div');
cell.style.cssText = this._slotStyle(s, prefix === 'h' && i === this.active).replace('52px', '56px');
cell.innerHTML = (prefix === 'h' ? `<span style="position:absolute;left:3px;top:1px;font-size:11px;color:#9aa3b2;font-weight:700">${i + 1}</span>` : '') + this._slotInner(s);
cell.onmouseenter = (e) => this._showTooltip(s, e);
cell.onmouseleave = () => this._hideTooltip();
cell.oncontextmenu = (e) => { e.preventDefault(); this._openCtx(ref, e); };
if (prefix === 'h') cell.onclick = () => this.setActiveHotbar(i);
this._wireDrag(cell, ref);
el.appendChild(cell);
}
};
build(this._gridEl, this.grid, 'g');
if (this._hbEl) build(this._hbEl, this.hotbar, 'h');
}
// ── Drag-drop (HTML5 native) ────────────────────────────────────────────
_wireDrag(cell, ref) {
cell.draggable = true;
cell.addEventListener('dragstart', (e) => {
this._drag = ref; cell.style.opacity = '0.4';
try { e.dataTransfer.setData('text/plain', String(ref)); e.dataTransfer.effectAllowed = 'move'; } catch (er) {}
});
cell.addEventListener('dragend', () => { cell.style.opacity = '1'; this._drag = null; });
cell.addEventListener('dragover', (e) => { e.preventDefault(); });
cell.addEventListener('drop', (e) => {
e.preventDefault();
const from = this._drag;
if (from != null && String(from) !== String(ref)) this.move(from, ref);
});
}
// ── Tooltip ──────────────────────────────────────────────────────────────
_showTooltip(s, e) {
if (!s) return;
this._hideTooltip();
const def = this._def(s.itemId), rc = RARITY[def.rarity];
const t = document.createElement('div');
t.style.cssText = 'position:absolute;z-index:90;max-width:240px;padding:10px 12px;background:rgba(12,16,26,0.96);border:1px solid ' + rc.color + ';border-radius:10px;color:#e8ecf2;font-family:Inter,system-ui,sans-serif;font-size:13px;pointer-events:none;box-shadow:0 6px 20px rgba(0,0,0,0.5)';
t.innerHTML =
'<div style="font-weight:800;color:' + rc.color + '">' + this._esc(def.name) + '</div>' +
'<div style="font-size:11px;color:#9aa3b2;margin:2px 0">' + rc.label + (def.tags.length ? ' · ' + def.tags.join(', ') : '') + '</div>' +
(def.description ? '<div style="margin-top:4px">' + this._esc(def.description) + '</div>' : '') +
(def.value ? '<div style="margin-top:4px;color:#ffd23a">💰 ' + def.value + '</div>' : '');
document.body.appendChild(t);
const x = (e && e.clientX) || 0, y = (e && e.clientY) || 0;
t.style.left = Math.min(x + 14, window.innerWidth - 250) + 'px';
t.style.top = (y + 14) + 'px';
this.tooltip = t;
}
_hideTooltip() { if (this.tooltip) { try { this.tooltip.remove(); } catch (e) {} this.tooltip = null; } }
// ── ПКМ-меню (Use/Split/Drop) ─────────────────────────────────────────────
_openCtx(ref, e) {
this._closeCtx();
const { arr, idx } = this._arrIdx(ref);
const s = arr[idx]; if (!s) return;
const m = document.createElement('div');
m.style.cssText = 'position:absolute;z-index:95;background:#1a2030;border:1px solid rgba(255,255,255,0.15);border-radius:10px;padding:5px;min-width:140px;font-family:Inter,system-ui,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,0.5)';
const item = (label, fn) => {
const b = document.createElement('div');
b.textContent = label;
b.style.cssText = 'padding:8px 12px;border-radius:7px;cursor:pointer;color:#e8ecf2;font-size:14px';
b.onmouseenter = () => b.style.background = 'rgba(255,255,255,0.08)';
b.onmouseleave = () => b.style.background = 'transparent';
b.onclick = () => { fn(); this._closeCtx(); };
m.appendChild(b);
};
item('Использовать', () => this.use(ref));
if (this._opts.allowSplit && s.count > 1) item('Разделить', () => this.split(ref, Math.floor(s.count / 2)));
if (this._opts.allowDrop && !this._def(s.itemId).tags.includes('quest')) item('Выбросить', () => { arr[idx] = null; this._changed(); });
item('Отмена', () => {});
document.body.appendChild(m);
m.style.left = Math.min((e.clientX || 0), window.innerWidth - 150) + 'px';
m.style.top = (e.clientY || 0) + 'px';
this.ctxMenu = m;
setTimeout(() => { this._ctxCloser = () => this._closeCtx(); window.addEventListener('click', this._ctxCloser, { once: true }); }, 0);
}
_closeCtx() { if (this.ctxMenu) { try { this.ctxMenu.remove(); } catch (e) {} this.ctxMenu = null; } }
_esc(str) { return String(str).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
// ── Сериализация ──────────────────────────────────────────────────────────
serialize() {
return { defs: [...this.defs.values()], grid: this.grid, hotbar: this.hotbar, active: this.active, opts: this._opts };
}
load(data) {
if (!data) return;
if (Array.isArray(data.defs)) for (const d of data.defs) this.defineItem(d);
if (Array.isArray(data.grid)) this.grid = data.grid.slice(0, GRID).concat(new Array(Math.max(0, GRID - data.grid.length)).fill(null));
if (Array.isArray(data.hotbar)) this.hotbar = data.hotbar.slice(0, HOTBAR).concat(new Array(Math.max(0, HOTBAR - data.hotbar.length)).fill(null));
if (typeof data.active === 'number') this.active = data.active;
if (data.opts) this._opts = { ...this._opts, ...data.opts };
}
dispose() {
this.close();
if (this.hotbarRoot) { try { this.hotbarRoot.remove(); } catch (e) {} this.hotbarRoot = null; }
}
resetRuntime() {
this.close();
if (this.hotbarRoot) { try { this.hotbarRoot.remove(); } catch (e) {} this.hotbarRoot = null; }
}
}

View File

@ -1,255 +0,0 @@
/**
* LeaderstatsManager лидерборды (leaderstats) как в Roblox (задача 20).
*
* Хранит статы игроков и рендерит HUD-таблицу в правом-верхнем углу.
* В одиночной игре один игрок ('me'). Поля сортируются по primary-стату.
*
* API (через game.leaderstats.*):
* define(name, opts) зарегистрировать стат (initial/format/icon/color/primary)
* set(playerId, name, value) / add изменить стат игрока
* get(playerId, name) прочитать
* me.set/add(name, value) для текущего игрока
* onChange(fn) подписка (для bindToStat достижений)
*
* format: 'number' (42) | 'time' (mm:ss) | 'short' (1.2K).
* DOM-оверлей крепится к canvas.parentElement (как LoadingScreenOverlay).
*
* Фича-парность: тот же модуль в rublox-player/src/engine/.
*/
function fmt(value, format) {
const v = Number(value) || 0;
if (format === 'time') {
const m = Math.floor(v / 60), s = Math.floor(v % 60);
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
}
if (format === 'short') {
if (v >= 1e9) return (v / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
if (v >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
if (v >= 1e3) return (v / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
return String(Math.round(v));
}
return String(Math.round(v));
}
export class LeaderstatsManager {
constructor(scene3d) {
this.s = scene3d;
this._defs = []; // [{name, initial, format, icon, color, primary}]
this._stats = new Map(); // playerId → Map(name → value)
this._players = new Map(); // playerId → displayName
this._onChange = [];
this.root = null;
this._dirty = false;
this._meId = 'me';
}
/** id текущего игрока (одиночка = 'me'). */
_resolveMe() {
try {
const p = this.s?.gameRuntime?._players?.me;
if (p && p.id != null) return String(p.id);
} catch (e) { /* ignore */ }
return 'me';
}
define(name, opts = {}) {
if (typeof name !== 'string' || !name) return;
if (this._defs.some(d => d.name === name)) return; // уже есть
this._defs.push({
name,
initial: Number(opts.initial) || 0,
format: opts.format || 'number',
icon: opts.icon || '',
color: opts.color || '#e8ecf2',
primary: !!opts.primary,
});
// Если ни один не primary — первый становится primary.
if (!this._defs.some(d => d.primary)) this._defs[0].primary = true;
// Инициализируем стат у уже известных игроков.
for (const [pid] of this._players) this._ensure(pid, name);
this._ensureMe();
if (this.s?._isPlaying) this._mount(); // HUD только в Play
this._dirty = true;
}
_ensureMe() {
const me = this._resolveMe();
this._meId = me;
if (!this._players.has(me)) {
let nm = 'Ты';
try { nm = this.s?.gameRuntime?._players?.me?.name || 'Ты'; } catch (e) {}
this._players.set(me, nm);
}
for (const d of this._defs) this._ensure(me, d.name);
}
_ensure(pid, name) {
if (!this._stats.has(pid)) this._stats.set(pid, new Map());
const m = this._stats.get(pid);
if (!m.has(name)) {
const def = this._defs.find(d => d.name === name);
m.set(name, def ? def.initial : 0);
}
}
set(playerId, name, value) {
const pid = playerId == null ? this._resolveMe() : String(playerId);
if (!this._players.has(pid)) this._players.set(pid, pid === this._resolveMe() ? 'Ты' : ('Игрок ' + pid));
this._ensure(pid, name);
const m = this._stats.get(pid);
const old = m.get(name);
const nv = Number(value) || 0;
if (old === nv) return;
m.set(name, nv);
this._dirty = true;
this._flash = this._flash || {};
this._flash[pid + '|' + name] = performance.now ? performance.now() : Date.now();
for (const fn of this._onChange) {
try { fn(pid, name, nv, old); } catch (e) { /* ignore */ }
}
// Сохраняем статы текущего игрока в БД (дебаунс 1с) — между сессиями.
if (pid === this._resolveMe()) this._scheduleSave();
}
_scheduleSave() {
if (this._saveTimer) clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => {
this._saveTimer = null;
try {
const me = this._resolveMe();
const m = this._stats.get(me);
if (!m) return;
const obj = {}; for (const [k, v] of m) obj[k] = v;
this.s?.gameRuntime?.saveProgress?.('_leaderstats', obj);
} catch (e) { /* ignore */ }
}, 1000);
}
/** Загрузить статы текущего игрока из БД (вызывать при Play, после define). */
loadFromDB() {
const rt = this.s?.gameRuntime;
if (!rt || !rt.loadProgress) return;
rt.loadProgress('_leaderstats', (data) => {
if (data && typeof data === 'object') {
const me = this._resolveMe();
for (const name of Object.keys(data)) {
// Применяем только к зарегистрированным статам, без повторного сейва.
if (this._defs.some(d => d.name === name)) {
this._ensure(me, name);
this._stats.get(me).set(name, Number(data[name]) || 0);
}
}
this._dirty = true;
}
});
}
add(playerId, name, delta) {
const pid = playerId == null ? this._resolveMe() : String(playerId);
this._ensure(pid, name);
const cur = this._stats.get(pid).get(name) || 0;
this.set(pid, name, cur + (Number(delta) || 0));
}
get(playerId, name) {
const pid = playerId == null ? this._resolveMe() : String(playerId);
const m = this._stats.get(pid);
return m ? (m.get(name) || 0) : 0;
}
onChange(fn) { if (typeof fn === 'function') this._onChange.push(fn); }
/** Активны ли leaderstats (хотя бы один define). */
get active() { return this._defs.length > 0; }
// ── HUD ──────────────────────────────────────────────────────────────
_mount() {
if (this.root) return;
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
const root = document.createElement('div');
root.style.cssText = [
'position:absolute', 'top:14px', 'right:14px', 'z-index:50',
'min-width:230px', 'max-width:300px',
'background:rgba(18,22,33,0.55)', 'backdrop-filter:blur(8px)',
'-webkit-backdrop-filter:blur(8px)',
'border:1px solid rgba(255,255,255,0.12)', 'border-radius:12px',
'padding:10px 12px', 'font-family:Inter,system-ui,sans-serif',
'color:#e8ecf2', 'pointer-events:none', 'user-select:none',
'box-shadow:0 6px 24px rgba(0,0,0,0.35)',
].join(';');
parent.appendChild(root);
this.root = root;
this._sortBy = null; // имя стата для сортировки (null = primary)
}
/** Вызывать каждый кадр (рендер при изменениях + затухание flash). */
tick() {
if (!this.active) return;
if (!this.root) { this._mount(); this._dirty = true; }
if (this._dirty) { this._render(); this._dirty = false; }
// flash затухает ~600мс — перерисуем пока активен.
if (this._flash && Object.keys(this._flash).length) {
const now = performance.now ? performance.now() : Date.now();
let any = false;
for (const k of Object.keys(this._flash)) {
if (now - this._flash[k] < 600) any = true; else delete this._flash[k];
}
if (any) this._render();
}
}
_render() {
const defs = this._defs;
if (!defs.length) { this.root.innerHTML = ''; return; }
const sortStat = this._sortBy || (defs.find(d => d.primary) || defs[0]).name;
const me = this._resolveMe();
// Строки игроков, сортировка по убыванию sortStat, топ-10.
const rows = [...this._players.keys()]
.map(pid => ({ pid, name: this._players.get(pid) }))
.sort((a, b) => (this.get(b.pid, sortStat) - this.get(a.pid, sortStat)))
.slice(0, 10);
const now = performance.now ? performance.now() : Date.now();
let html = '<div style="display:flex;align-items:center;gap:6px;font-weight:800;font-size:13px;margin-bottom:8px;color:#ffd23a">🏆 Таблица лидеров</div>';
// Шапка столбцов.
html += '<div style="display:grid;grid-template-columns:1fr ' + defs.map(() => 'auto').join(' ') + ';gap:8px;font-size:11px;color:#9aa3b2;font-weight:700;padding-bottom:4px;border-bottom:1px solid rgba(255,255,255,0.1)">';
html += '<span>Игрок</span>';
for (const d of defs) html += '<span style="text-align:right;color:' + d.color + '">' + (d.icon ? d.icon + ' ' : '') + d.name + '</span>';
html += '</div>';
// Строки.
for (const r of rows) {
const mine = r.pid === me;
html += '<div style="display:grid;grid-template-columns:1fr ' + defs.map(() => 'auto').join(' ') + ';gap:8px;font-size:13px;padding:4px 2px;border-radius:6px;' + (mine ? 'background:rgba(51,87,255,0.22);' : '') + '">';
html += '<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:' + (mine ? '800' : '600') + '">' + this._esc(r.name) + '</span>';
for (const d of defs) {
const flashed = this._flash && (now - (this._flash[r.pid + '|' + d.name] || 0) < 600);
const col = flashed ? '#ffe066' : d.color;
html += '<span style="text-align:right;font-weight:700;color:' + col + ';transition:color .2s">' + fmt(this.get(r.pid, d.name), d.format) + '</span>';
}
html += '</div>';
}
this.root.innerHTML = html;
}
_esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
/** Сериализация определений в project_data. */
serialize() {
return this._defs.map(d => ({ ...d }));
}
load(arr) {
if (!Array.isArray(arr)) return;
for (const d of arr) this.define(d.name, d);
}
dispose() {
if (this.root) { try { this.root.remove(); } catch (e) {} this.root = null; }
this._stats.clear(); this._players.clear(); this._onChange = [];
}
/** Сброс рантайм-значений при exitPlayMode (определения остаются). */
resetRuntime() {
this._stats.clear(); this._players.clear(); this._flash = {};
if (this.root) this.root.innerHTML = '';
}
}

View File

@ -542,7 +542,6 @@ export class ModelManager {
opacity: typeof data.opacity === 'number' ? data.opacity : 1,
tint: data.tint || null,
name: data.name || null,
...(data.folderId != null ? { folderId: data.folderId } : {}), // папка
// Параметры геймплея (HP, скорость врага, лимит спавнера и т.п.)
gameplayParams: data.gameplayParams || null,
});
@ -776,7 +775,6 @@ export class ModelManager {
if (m.tint) data.tint = m.tint;
if (m.name) data.name = m.name;
if (m.gameplayParams) data.gameplayParams = m.gameplayParams;
if (m.folderId != null) data.folderId = m.folderId;
if (data.opacity != null || data.tint) this._applyMaterialOverrides(data);
}
}

View File

@ -161,19 +161,6 @@ export class NpcManager {
r15Animator,
};
this.npcs.set(id, npc);
// Пометить меши NPC для попаданий оружия (бластер/меч): pickable + npcId
// в metadata. Без pickable raycast оружия проходит сквозь NPC (задача 40).
try {
const root = npc.data && npc.data.rootMesh;
if (root) {
root.isPickable = true;
root.metadata = Object.assign({}, root.metadata, { npcId: id });
for (const m of root.getChildMeshes(false)) {
m.isPickable = true;
m.metadata = Object.assign({}, m.metadata, { npcId: id });
}
}
} catch (e) { /* ignore */ }
return id;
}
@ -288,12 +275,6 @@ export class NpcManager {
npc.isMoving = false;
}
/** Включить/выключить анимацию атаки. */
setAttacking(id, on) {
const npc = this.npcs.get(Number(id));
if (npc) npc.attacking = !!on;
}
/** Реплика над головой NPC на duration секунд. */
say(id, text, duration = 3) {
const npc = this.npcs.get(Number(id));
@ -306,41 +287,10 @@ export class NpcManager {
damage(id, amount) {
const npc = this.npcs.get(Number(id));
if (!npc || npc.dead) return;
const amt = Number(amount) || 0;
npc.hp = Math.max(0, npc.hp - amt);
// Авто-floater над мобом (задача 40 доп): game.fx.autoMobFloaters(true).
if (this._autoFloater && amt > 0 && this.scene3d?.floaters) {
try {
this.scene3d.floaters.spawn(
{ x: npc.x, y: (npc.y || 0) + 2.2, z: npc.z }, amt, this._autoFloater.opts || {});
} catch (e) { /* ignore */ }
}
npc.hp = Math.max(0, npc.hp - (Number(amount) || 0));
if (npc.hp <= 0) this._killNpc(npc);
}
/** Нанести урон NPC по мешу-попаданию (бластер/оружие). Ищет NPC, чьи меши
* содержат hit-меш (или предка). Вызывает damage() авто-floater. */
damageByMesh(mesh, amount) {
if (!mesh) return false;
let m = mesh;
for (let i = 0; i < 8 && m; i++) {
const nid = m.metadata && m.metadata.npcId;
if (nid != null && this.npcs.has(nid)) { this.damage(nid, amount); return true; }
m = m.parent;
}
for (const npc of this.npcs.values()) {
if (npc.dead) continue;
const root = npc.data && npc.data.rootMesh;
if (!root) continue;
let mm = mesh;
for (let i = 0; i < 8 && mm; i++) {
if (mm === root) { this.damage(npc.id, amount); return true; }
mm = mm.parent;
}
}
return false;
}
/** Удалить NPC по id (без эффекта смерти — просто убрать). */
removeNpc(id) {
const npc = this.npcs.get(Number(id));
@ -441,22 +391,17 @@ export class NpcManager {
if (root._isWorldMatrixFrozen) {
try { root.unfreezeWorldMatrix(); } catch (e) {}
}
// Процедурная анимация ходьбы (у Kenney-моделей нет скелета).
if (moving) npc.walkPhase += dt * 10;
let bobY = 0, lean = 0;
if (moving && !npc.r15Animator) {
bobY = Math.abs(Math.sin(npc.walkPhase)) * 0.12;
lean = Math.sin(npc.walkPhase) * 0.08;
}
root.position.set(npc.x, npc.y + bobY, npc.z);
root.position.set(npc.x, npc.y, npc.z);
root.rotation.y = npc.yaw;
root.rotation.z = lean;
// data.x/y/z — чтобы scene.find/getPosition видели NPC.
data.x = npc.x; data.y = npc.y; data.z = npc.z;
// Анимация ходьбы — простое покачивание (без R15-скелета у Kenney).
if (moving) npc.walkPhase += dt * 6;
// R15-NPC (skin_*): процедурная анимация бега/покоя через R15Animator.
if (npc.r15Animator) {
try {
npc.r15Animator.setState(npc.attacking ? 'attack' : (moving ? 'run' : 'idle'));
npc.r15Animator.setState(moving ? 'run' : 'idle');
npc.r15Animator.update(dt);
} catch (e) { /* ignore */ }
}

View File

@ -156,7 +156,6 @@ export class PrimitiveManager {
primitiveId: id,
primitiveType: type,
primitiveKind: typeDef.kind,
canCollide, // нужен camera-clamp: камера не цепляется за зоны canCollide:false
};
// textureAsset — id картинки из AssetManager (пользовательская
@ -710,10 +709,7 @@ export class PrimitiveManager {
this._applyMaterial(data.mesh, typeDef, data.color, data.material);
}
if (patch.canCollide !== undefined) {
data.canCollide = patch.canCollide;
if (data.mesh?.metadata) data.mesh.metadata.canCollide = patch.canCollide;
}
if (patch.canCollide !== undefined) data.canCollide = patch.canCollide;
if (patch.locked !== undefined) data.locked = !!patch.locked;
if (patch.visible !== undefined) {
data.visible = patch.visible;
@ -865,7 +861,6 @@ export class PrimitiveManager {
anchored: d.anchored,
mass: d.mass,
name: d.name || null,
...(d.folderId != null ? { folderId: d.folderId } : {}), // папка (парность со студией)
// locked — защита от выделения/перемещения (Фаза 5.11).
...(d.locked ? { locked: true } : {}),
// id пользовательской текстуры (картинка из AssetManager).

View File

@ -131,18 +131,6 @@ const ANIMS_STD = {
{ bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 10,
times: [0.0, 0.4, 0.8], values: [0.0, 1.0, 0.0] },
]),
attack: makeAnim(0.5, true, [
{ bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -95,
times: [0.0, 0.15, 0.3, 0.5], values: [0.3, 1.0, 0.2, 0.3] },
{ bone: B.RIGHT_FOREARM, axis: AXIS_RIGHT, angleDeg: -50,
times: [0.0, 0.15, 0.3, 0.5], values: [1.0, 0.2, 1.0, 1.0] },
{ bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: -45,
times: [0.0, 0.5], values: [1.0, 1.0] },
{ bone: B.LEFT_FOREARM, axis: AXIS_RIGHT, angleDeg: -70,
times: [0.0, 0.5], values: [1.0, 1.0] },
{ bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: -12,
times: [0.0, 0.15, 0.3, 0.5], values: [0.0, 1.0, 0.3, 0.0] },
]),
// === ЭМОЦИИ (game.player.playAnimation) ===
// Разовые анимации поверх авто-состояния. loop=false — играют один раз,

View File

@ -74,10 +74,6 @@ let _invUiSlotClickHandlers = [];
let _players = { me: null, list: [] };
// Общее состояние комнаты game.room.get/set — зеркало из main thread.
let _roomState = {};
// Задача 20: зеркала лидербордов/достижений для синхронного get/has в скриптах.
let _lsMirror = {};
let _achUnlocked = {};
let _lsChangeHandlers = [];
// Подписки game.onPlayerJoin / onPlayerLeave / game.onMessage(name).
let _playerJoinHandlers = [];
let _playerLeaveHandlers = [];
@ -223,10 +219,6 @@ function _makeNpcProxy(ref) {
damage(amount) {
_send('npc.damage', { ref, amount: Number(amount) || 0 });
},
/** Включить/выключить анимацию атаки. */
setAttacking(on) {
_send('npc.setAttacking', { ref, on: !!on });
},
/** Убрать NPC со сцены. */
remove() {
_send('npc.remove', { ref });
@ -475,11 +467,6 @@ function _getOrCreateInstance(ref, kindHint) {
_send('scene.setColor', { ref, color: String(value) });
return true;
}
if (prop === 'scale') {
const k = Number(value);
if (Number.isFinite(k) && k >= 0) _send('scene.setScale', { ref, scale: k });
return true;
}
if (prop === 'transparency' || prop === 'opacity') {
const v = Number(value);
if (Number.isFinite(v)) {
@ -682,47 +669,6 @@ function _buildSelfApi() {
_send('self.move', { target: _target, x: nx, y: ny, z: nz });
}
},
/** Повернуть объект-носитель вокруг оси Y на угол ry (радианы). */
rotate(ry) {
const r = Number(ry);
if (!Number.isFinite(r)) return;
const k = _target.kind;
const id = _target.id ?? _target.ref;
_send('scene.rotate', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, rotationY: r });
},
rotateY(ry) { this.rotate(ry); },
/** Показать/скрыть объект-носитель. */
setVisible(vis) {
const k = _target.kind;
const id = _target.id ?? _target.ref;
_send('scene.setVisible', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, visible: !!vis });
},
/** Включить/выключить столкновения объекта-носителя. */
setCollide(can) {
const k = _target.kind;
const id = _target.id ?? _target.ref;
_send('scene.setCollide', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, canCollide: !!can });
},
/** Перекрасить объект-носитель (только примитив). */
setColor(hex) {
if (typeof hex !== 'string') return;
const k = _target.kind;
const id = _target.id ?? _target.ref;
_send('scene.setColor', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, color: hex });
},
/** Повесить текст-метку над объектом-носителем. */
setLabel(text, opts) {
const k = _target.kind;
const id = _target.id ?? _target.ref;
const ref = (k && id != null) ? (k + ':' + id) : undefined;
_send('scene.setLabel', { ref, text: String(text == null ? '' : text), opts: opts || {} });
},
clearLabel() {
const k = _target.kind;
const id = _target.id ?? _target.ref;
const ref = (k && id != null) ? (k + ':' + id) : undefined;
_send('scene.clearLabel', { ref });
},
delete() {
_send('self.delete', { target: _target });
},
@ -1813,14 +1759,6 @@ const game = {
const bag = _dataIndex[ref];
return bag ? bag[key] : undefined;
},
// === Небо и атмосфера (задача 16) ===
setSkybox(opts) { _send('scene.setSkybox', { opts: opts || {} }); },
setClouds(opts) { _send('scene.setClouds', { opts: opts || {} }); },
setFog(opts) { _send('scene.setFog', { opts: opts || {} }); },
skybox: {
fadeTo(opts, durationSec) { _send('scene.skyboxFadeTo', { opts: opts || {}, duration: Number(durationSec) || 2 }); },
setSunDirection(dir) { _send('scene.skyboxSunDir', { dir: dir || {} }); },
},
/**
* Теги объектов (Фаза 5.6) как CollectionService в Roblox.
* Помечаешь объекты тегом, потом находишь все объекты с тегом.
@ -2305,10 +2243,6 @@ const game = {
opts = opts && typeof opts === 'object' ? opts : {};
this._opts = opts;
this._active = true;
// Колбэки можно передавать прямо в опциях show({ onPlay, onShow, onHide }).
if (typeof opts.onPlay === 'function') this._onPlay.push(opts.onPlay);
if (typeof opts.onShow === 'function') this._onShow.push(opts.onShow);
if (typeof opts.onHide === 'function') this._onHide.push(opts.onHide);
_send('player.setInputBlocked', { blocked: true });
game.hud.setVisible(false);
this._camCfg = opts.camera || { mode: 'orbit', center: { x: 0, y: 1, z: 0 }, radius: 6, height: 2, duration: 12 };
@ -2807,20 +2741,6 @@ const game = {
clear() {
_send('inventory.clear', {});
},
// === Задача 44: drag-drop инвентарь ===
give(itemId, count) { _send('inv2.add', { itemId, count: Number(count) || 1 }); },
take(itemId, count) { _send('inv2.remove', { itemId, count: Number(count) || 1 }); },
open() { _send('inv2.open', {}); },
closeUi() { _send('inv2.close', {}); },
toggle() { _send('inv2.toggle', {}); },
sort(by) { _send('inv2.sort', { by: by || 'rarity' }); },
setActiveHotbar(i) { _send('inv2.setActive', { i: Number(i) || 0 }); },
},
items: {
define(def) {
if (Array.isArray(def)) { for (const d of def) _send('items.define', { def: d }); return; }
_send('items.define', { def: def || {} });
},
},
/**
* Игроки комнаты (Фаза 4.3 мультиплеер).
@ -2848,50 +2768,6 @@ const game = {
return p ? { ...p } : null;
},
},
// === Лидерборды (leaderstats) — задача 20 ===
leaderstats: {
define(name, opts) {
if (typeof name !== 'string' || !name) return;
_send('leaderstats.define', { name, opts: opts || {} });
},
set(playerId, name, value) {
_send('leaderstats.set', { playerId: playerId == null ? null : String(playerId), name, value: Number(value) || 0 });
const pid = playerId == null ? '@me' : String(playerId);
if (!_lsMirror[pid]) _lsMirror[pid] = {};
_lsMirror[pid][name] = Number(value) || 0;
},
add(playerId, name, delta) {
_send('leaderstats.add', { playerId: playerId == null ? null : String(playerId), name, delta: Number(delta) || 0 });
const pid = playerId == null ? '@me' : String(playerId);
if (!_lsMirror[pid]) _lsMirror[pid] = {};
_lsMirror[pid][name] = (_lsMirror[pid][name] || 0) + (Number(delta) || 0);
},
get(playerId, name) {
const pid = playerId == null ? '@me' : String(playerId);
return (_lsMirror[pid] && _lsMirror[pid][name]) || 0;
},
onChange(fn) { if (typeof fn === 'function') _lsChangeHandlers.push(fn); },
me: {
set(name, value) { game.leaderstats.set(null, name, value); },
add(name, delta) { game.leaderstats.add(null, name, delta); },
get(name) { return game.leaderstats.get(null, name); },
},
},
// === Достижения — задача 20 ===
achievements: {
define(list) { _send('achievements.define', { list: Array.isArray(list) ? list : [list] }); },
unlock(id, playerId) {
if (typeof id !== 'string') return;
_achUnlocked[id] = true;
_send('achievements.unlock', { id, playerId: playerId == null ? null : String(playerId) });
},
has(id) { return !!_achUnlocked[id]; },
bindToStat(id, statName, cond) { _send('achievements.bindToStat', { id, statName, cond: cond || {} }); },
setButtonVisible(v) { _send('achievements.setButtonVisible', { visible: !!v }); },
openPage() { _send('achievements.openPage', {}); },
},
/**
* Общее состояние комнаты (Фаза 4.3) данные, видимые всем игрокам.
* В одиночной игре работает как локальное хранилище.
@ -3034,25 +2910,6 @@ const game = {
* trail шлейф за движущимся объектом.
*/
fx: {
/**
* Всплывающая цифра урона (задача 40). position {x,y,z} или ref;
* value число/строка; opts color/isCrit/isHeal/isMana/isMiss/
* fontSize/floatHeight/lifetime/randomOffset/stackKey/comicStyle.
*/
damageFloater(position, value, opts) {
const pos = _normFxPoint(position);
_send('fx.damageFloater', { position: pos, value, opts: opts || {} });
},
/**
* Авто-floater'ы над мобами (NPC) при потере HP (задача 40 доп).
* Включил один раз и любой урон по NPC (от бластера, скрипта, врага)
* сам показывает облачко «-N» над целью.
* game.fx.autoMobFloaters(true);
* game.fx.autoMobFloaters(true, { color:'#ff5a4a' });
*/
autoMobFloaters(enabled, opts) {
_send('fx.autoMobFloaters', { enabled: enabled !== false, opts: opts || {} });
},
/**
* Луч между двумя точками. opts: { from, to {x,y,z} или ref
* объекта (тогда луч следит за ним); color: '#hex', width }.
@ -3934,15 +3791,6 @@ self.onmessage = (e) => {
const t = payload?.type;
if (t === 'click') {
for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick');
} else if (t === 'leaderstatsChange') {
// Задача 20: стат изменился на main — обновляем зеркало + onChange.
const pid = payload.playerId == null ? '@me' : String(payload.playerId);
if (!_lsMirror[pid]) _lsMirror[pid] = {};
_lsMirror[pid][payload.name] = payload.newValue;
if (payload.isMe) { if (!_lsMirror['@me']) _lsMirror['@me'] = {}; _lsMirror['@me'][payload.name] = payload.newValue; }
for (const fn of _lsChangeHandlers) { try { fn(payload.playerId, payload.name, payload.newValue, payload.oldValue); } catch (err) { _send('log', { level: 'error', text: 'leaderstats.onChange: ' + (err && err.message ? err.message : err) }); } }
} else if (t === 'achievementUnlocked') {
_achUnlocked[payload.id] = true;
} else if (t === 'mouseMove') {
for (const fn of _mouseMoveHandlers) {
try { fn(payload.x, payload.y); }

View File

@ -1,570 +0,0 @@
/**
* SkyboxManager кастомное небо для сцены (задача 16).
*
* Реализует процедурный gradient-skybox без внешних текстур (работает offline):
* - купол-сфера (BACKSIDE) с ShaderMaterial: плавный градиент верхниз,
* солнечный диск, лёгкая дымка у горизонта;
* - low-poly горы на горизонте (как в Roblox-эталоне);
* - billboard-облака (плоскости, медленный дрейф);
* - атмосферный туман (scene.fog).
*
* Пресеты: clear-summer-day / cloudy / sunset / starry-night / space /
* lowpoly-roblox (+ gradient вручную). Плавный fadeTo между ними.
*
* API (через game.scene.*):
* setSkybox({ preset } | { mode:'gradient', topColor, bottomColor, ... })
* setClouds({ enabled, cover, density, speed, color })
* setFog({ color, density, near, far } | enabled:false)
* skybox.fadeTo(opts, durationSec)
* skybox.setSunDirection({x,y,z})
*
* Фича-парность: при портировании в плеер тот же модуль в rublox-player/src/engine/.
*/
import {
Color3, Color4, Vector3,
MeshBuilder, ShaderMaterial, Effect, StandardMaterial, Texture,
DynamicTexture, VertexData, Mesh,
} from '@babylonjs/core';
// ── Шейдер градиентного неба ──────────────────────────────────────────────
// Купол изнутри: цвет интерполируется по высоте (y от -1 низ до +1 верх),
// плюс солнечный диск и осветление у горизонта (дымка).
const SKY_VERT = `
precision highp float;
attribute vec3 position;
uniform mat4 worldViewProjection;
varying vec3 vDir;
void main(void){
vDir = normalize(position);
gl_Position = worldViewProjection * vec4(position, 1.0);
}`;
const SKY_FRAG = `
precision highp float;
varying vec3 vDir;
uniform vec3 topColor;
uniform vec3 bottomColor;
uniform vec3 horizonColor;
uniform vec3 sunDir;
uniform vec3 sunColor;
uniform float sunSize; // 0..1 угловой радиус
uniform float horizonHaze; // 0..1 сила дымки у горизонта
void main(void){
float h = clamp(vDir.y * 0.5 + 0.5, 0.0, 1.0); // 0 низ .. 1 верх
// Градиент: низ→горизонт→верх (двойная интерполяция через горизонт у h~0.5)
vec3 col;
if (h < 0.5) {
col = mix(bottomColor, horizonColor, h * 2.0);
} else {
col = mix(horizonColor, topColor, (h - 0.5) * 2.0);
}
// Дымка у горизонта — осветление узкой полосы около h=0.5
float haze = smoothstep(0.5, 0.0, abs(h - 0.5)) * horizonHaze;
col = mix(col, horizonColor + vec3(0.08), haze * 0.5);
// Солнечный диск + гало
float d = distance(normalize(vDir), normalize(sunDir));
float disk = smoothstep(sunSize, sunSize * 0.4, d);
float glow = smoothstep(sunSize * 6.0, 0.0, d) * 0.35;
col += sunColor * disk;
col += sunColor * glow;
gl_FragColor = vec4(col, 1.0);
}`;
let _shaderRegistered = false;
function registerSkyShader() {
if (_shaderRegistered) return;
Effect.ShadersStore['kubikonSkyVertexShader'] = SKY_VERT;
Effect.ShadersStore['kubikonSkyFragmentShader'] = SKY_FRAG;
_shaderRegistered = true;
}
const hexToRgb = (hex) => {
if (Array.isArray(hex)) return hex;
let h = String(hex || '#ffffff').replace('#', '').trim();
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
if (h.length < 6) h = (h + 'ffffff').slice(0, 6);
const r = parseInt(h.substring(0, 2), 16);
const g = parseInt(h.substring(2, 4), 16);
const b = parseInt(h.substring(4, 6), 16);
return [
(Number.isFinite(r) ? r : 255) / 255,
(Number.isFinite(g) ? g : 255) / 255,
(Number.isFinite(b) ? b : 255) / 255,
];
};
// ── Пресеты неба ──────────────────────────────────────────────────────────
// top/bottom/horizon — цвета купола; sun — направление/цвет/размер солнца;
// mountains — рисовать ли горы; clouds — параметры облаков; fog — туман;
// stars — звёздное небо (для ночи/космоса).
const PRESETS = {
'clear-summer-day': {
top: '#3d7fe0', horizon: '#bcd9f2', bottom: '#dcebf7',
sunDir: [0.3, 0.85, 0.4], sunColor: '#fff6d8', sunSize: 0.035, haze: 0.6,
mountains: false,
clouds: { enabled: true, cover: 0.25, color: '#ffffff', speed: 0.015 },
fog: { color: '#cfe2f2', density: 0.0035 },
light: { sunIntensity: 1.0, sunColor: '#fff6e0', hemiIntensity: 0.7, ambient: '#b9d4ee' },
},
'lowpoly-roblox': {
top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa',
sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.85,
mountains: true,
clouds: { enabled: true, cover: 0.45, color: '#ffffff', speed: 0.012 },
fog: { color: '#e2eef7', density: 0.005 },
light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' },
},
'cloudy': {
top: '#8fa6bd', horizon: '#c2ccd6', bottom: '#d8dde2',
sunDir: [0.2, 0.7, 0.3], sunColor: '#e8e8e8', sunSize: 0.0, haze: 0.4,
mountains: false,
clouds: { enabled: true, cover: 0.8, color: '#e8ebef', speed: 0.02 },
fog: { color: '#cfd6dd', density: 0.008 },
light: { sunIntensity: 0.5, sunColor: '#dfe4ea', hemiIntensity: 0.8, ambient: '#c2ccd6' },
},
'sunset': {
top: '#2a3a6b', horizon: '#f5915a', bottom: '#f7c98a',
sunDir: [0.7, 0.12, -0.2], sunColor: '#ffd28a', sunSize: 0.05, haze: 1.0,
mountains: true,
clouds: { enabled: true, cover: 0.35, color: '#ffd9b3', speed: 0.01 },
fog: { color: '#f0b483', density: 0.006 },
light: { sunIntensity: 0.7, sunColor: '#ff9a52', hemiIntensity: 0.5, ambient: '#9a6a78' },
},
'starry-night': {
top: '#070b1f', horizon: '#1b2547', bottom: '#243056',
sunDir: [-0.4, 0.75, 0.3], sunColor: '#cdd6ff', sunSize: 0.02, haze: 0.3,
mountains: true, stars: true,
clouds: { enabled: false },
fog: { color: '#141c38', density: 0.004 },
light: { sunIntensity: 0.18, sunColor: '#9aa8d8', hemiIntensity: 0.25, ambient: '#1b2547' },
},
'space': {
top: '#02030a', horizon: '#06070f', bottom: '#0a0c18',
sunDir: [0.5, 0.6, 0.4], sunColor: '#ffffff', sunSize: 0.015, haze: 0.0,
mountains: false, stars: true,
clouds: { enabled: false },
fog: { enabled: false },
light: { sunIntensity: 0.6, sunColor: '#ffffff', hemiIntensity: 0.2, ambient: '#10121c' },
},
};
export class SkyboxManager {
constructor(scene, hemiLight, sunLight) {
this.scene = scene;
this.hemiLight = hemiLight || null; // ambient
this.sunLight = sunLight || null; // directional (тени)
this._dome = null;
this._mat = null;
this._mountains = null;
this._clouds = []; // [{mesh, baseX, speed}]
this._cloudRoot = null;
this._stars = null;
this._fade = null; // активный fadeTo {from,to,t,dur}
this._state = this._defaultState();
registerSkyShader();
this._buildDome();
}
_defaultState() {
return {
mode: 'gradient',
top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa',
sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.8,
mountains: false, stars: false,
clouds: { enabled: false, cover: 0.4, color: '#ffffff', speed: 0.012 },
fog: { enabled: false, color: '#dde8f2', density: 0.005 },
light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' },
};
}
// ── Купол ──────────────────────────────────────────────────────────────
_buildDome() {
const dome = MeshBuilder.CreateSphere('kubikonSkyDome', {
diameter: 1000, segments: 24, sideOrientation: Mesh.BACKSIDE,
}, this.scene);
dome.isPickable = false;
dome.infiniteDistance = true; // не двигается с камерой
dome.renderingGroupId = 0;
dome.applyFog = false;
const mat = new ShaderMaterial('kubikonSkyMat', this.scene, {
vertex: 'kubikonSky', fragment: 'kubikonSky',
}, {
attributes: ['position'],
uniforms: ['worldViewProjection', 'topColor', 'bottomColor',
'horizonColor', 'sunDir', 'sunColor', 'sunSize', 'horizonHaze'],
});
mat.backFaceCulling = false;
mat.disableDepthWrite = true; // небо всегда позади
dome.material = mat;
this._dome = dome;
this._mat = mat;
this._applyShaderUniforms();
}
_applyShaderUniforms() {
const s = this._state;
const m = this._mat;
if (!m) return;
m.setColor3('topColor', Color3.FromArray(hexToRgb(s.top)));
m.setColor3('bottomColor', Color3.FromArray(hexToRgb(s.bottom)));
m.setColor3('horizonColor', Color3.FromArray(hexToRgb(s.horizon)));
const sd = Array.isArray(s.sunDir) ? s.sunDir : [0.4, 0.8, 0.45];
m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize());
m.setColor3('sunColor', Color3.FromArray(hexToRgb(s.sunColor)));
m.setFloat('sunSize', s.sunSize || 0.03);
m.setFloat('horizonHaze', s.haze != null ? s.haze : 0.7);
}
// ── Горы (low-poly на горизонте) ────────────────────────────────────────
_buildMountains(colorHex) {
this._disposeMountains();
const positions = [], indices = [];
const ringR = 420, baseY = -10, segs = 64;
// Кольцо из треугольных пиков переменной высоты — стилизованный силуэт.
let vi = 0;
for (let i = 0; i < segs; i++) {
const a0 = (i / segs) * Math.PI * 2;
const a1 = ((i + 1) / segs) * Math.PI * 2;
const am = (a0 + a1) / 2;
// Псевдослучайная высота пика (детерминированно от индекса).
const hh = 40 + Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.6)) * 130;
const x0 = Math.cos(a0) * ringR, z0 = Math.sin(a0) * ringR;
const x1 = Math.cos(a1) * ringR, z1 = Math.sin(a1) * ringR;
const xm = Math.cos(am) * ringR, zm = Math.sin(am) * ringR;
positions.push(x0, baseY, z0, x1, baseY, z1, xm, baseY + hh, zm);
indices.push(vi, vi + 1, vi + 2);
vi += 3;
}
const vd = new VertexData();
vd.positions = positions; vd.indices = indices;
const normals = [];
VertexData.ComputeNormals(positions, indices, normals);
vd.normals = normals;
const mesh = new Mesh('kubikonSkyMountains', this.scene);
vd.applyToMesh(mesh);
mesh.isPickable = false;
mesh.applyFog = true; // горы выцветают в туман (атмосфера)
mesh.renderingGroupId = 0;
const mat = new StandardMaterial('kubikonSkyMountainsMat', this.scene);
const c = hexToRgb(colorHex || '#8fa98a');
mat.diffuseColor = new Color3(c[0], c[1], c[2]);
mat.specularColor = new Color3(0, 0, 0);
mat.emissiveColor = new Color3(c[0] * 0.25, c[1] * 0.25, c[2] * 0.25);
mesh.material = mat;
this._mountains = mesh;
}
_disposeMountains() {
if (this._mountains) { this._mountains.material?.dispose(); this._mountains.dispose(); this._mountains = null; }
}
// ── Облака (billboard-плоскости) ────────────────────────────────────────
_buildClouds(opts) {
this._disposeClouds();
const o = opts || {};
if (!o.enabled) return;
const cover = o.cover != null ? o.cover : 0.4;
const count = Math.round(4 + cover * 16); // 4..20 облаков
const tex = this._makeCloudTexture(o.color || '#ffffff');
for (let i = 0; i < count; i++) {
const w = 60 + Math.random() * 90;
const plane = MeshBuilder.CreatePlane(`kubikonCloud_${i}`, { width: w, height: w * 0.55 }, this.scene);
plane.billboardMode = Mesh.BILLBOARDMODE_ALL;
plane.isPickable = false;
plane.applyFog = false;
plane.renderingGroupId = 0;
const mat = new StandardMaterial(`kubikonCloudMat_${i}`, this.scene);
mat.diffuseTexture = tex;
mat.opacityTexture = tex;
mat.emissiveColor = new Color3(1, 1, 1);
mat.disableLighting = true;
mat.backFaceCulling = false;
plane.material = mat;
const ang = Math.random() * Math.PI * 2;
const rad = 150 + Math.random() * 200;
const x = Math.cos(ang) * rad;
const z = Math.sin(ang) * rad;
const y = 90 + Math.random() * 70;
plane.position.set(x, y, z);
this._clouds.push({ mesh: plane, speed: (o.speed || 0.012) * (0.6 + Math.random() * 0.8) });
}
}
/** Процедурная мягкая текстура-облако (radial-gradient на DynamicTexture). */
_makeCloudTexture(colorHex) {
const size = 256;
const dt = new DynamicTexture('kubikonCloudTex', { width: size, height: size }, this.scene, false);
const ctx = dt.getContext();
ctx.clearRect(0, 0, size, size);
const c = hexToRgb(colorHex);
const rgb = `${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)}`;
// Несколько перекрывающихся мягких кругов → пухлое облако.
const blobs = [[128, 140, 70], [90, 150, 50], [170, 150, 55], [128, 110, 55], [110, 130, 45], [150, 130, 45]];
for (const [bx, by, br] of blobs) {
const g = ctx.createRadialGradient(bx, by, 0, bx, by, br);
g.addColorStop(0, `rgba(${rgb},0.9)`);
g.addColorStop(0.6, `rgba(${rgb},0.5)`);
g.addColorStop(1, `rgba(${rgb},0)`);
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
dt.hasAlpha = true;
dt.update();
return dt;
}
_disposeClouds() {
for (const c of this._clouds) { c.mesh.material?.diffuseTexture?.dispose?.(); c.mesh.material?.dispose(); c.mesh.dispose(); }
this._clouds = [];
}
// ── Звёзды (точки на куполе) ─────────────────────────────────────────────
_buildStars(enabled) {
this._disposeStars();
if (!enabled) return;
const size = 1024;
const dt = new DynamicTexture('kubikonStarsTex', { width: size, height: size }, this.scene, false);
const ctx = dt.getContext();
ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.clearRect(0, 0, size, size);
for (let i = 0; i < 600; i++) {
const x = Math.random() * size, y = Math.random() * size;
const r = Math.random() * 1.4 + 0.3;
const a = 0.4 + Math.random() * 0.6;
ctx.fillStyle = `rgba(255,255,255,${a})`;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
}
dt.hasAlpha = true; dt.update();
const dome = MeshBuilder.CreateSphere('kubikonStarsDome', {
diameter: 980, segments: 16, sideOrientation: Mesh.BACKSIDE,
}, this.scene);
dome.isPickable = false; dome.infiniteDistance = true;
dome.applyFog = false; dome.renderingGroupId = 0;
const mat = new StandardMaterial('kubikonStarsMat', this.scene);
mat.diffuseTexture = dt; mat.opacityTexture = dt;
mat.emissiveColor = new Color3(1, 1, 1);
mat.disableLighting = true; mat.backFaceCulling = false;
mat.disableDepthWrite = true;
dome.material = mat;
this._stars = dome;
}
_disposeStars() {
if (this._stars) { this._stars.material?.diffuseTexture?.dispose?.(); this._stars.material?.dispose(); this._stars.dispose(); this._stars = null; }
}
// ── Туман ────────────────────────────────────────────────────────────────
_applyFog(fog) {
if (!this.scene) return;
if (fog && fog.enabled !== false && (fog.density != null || fog.color)) {
this.scene.fogMode = 2; // EXP
const c = hexToRgb(fog.color || '#dde8f2');
this.scene.fogColor = new Color3(c[0], c[1], c[2]);
this.scene.fogDensity = fog.density != null ? fog.density : 0.005;
} else if (fog && fog.enabled === false) {
this.scene.fogMode = 0;
}
}
// ── Освещение (единый источник: небо управляет светом сцены) ─────────────
/** Выставить направление/яркость солнца и ambient под текущее небо. */
_applyLighting(light, sunDir) {
if (this.sunLight && sunDir) {
// DirectionalLight.direction указывает КУДА падает свет → от солнца вниз.
const d = new Vector3(-sunDir[0], -sunDir[1], -sunDir[2]);
if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; }
}
if (!light) return;
if (this.sunLight) {
if (typeof light.sunIntensity === 'number') this.sunLight.intensity = light.sunIntensity;
if (light.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(light.sunColor));
}
if (this.hemiLight) {
if (typeof light.hemiIntensity === 'number') this.hemiLight.intensity = light.hemiIntensity;
if (light.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(light.ambient));
}
}
// ── Public API ───────────────────────────────────────────────────────────
/** Применить пресет или ручные опции gradient. */
setSkybox(opts) {
if (!opts) return;
const preset = opts.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null;
const s = this._state;
if (preset) {
s.top = preset.top; s.horizon = preset.horizon; s.bottom = preset.bottom;
s.sunDir = preset.sunDir; s.sunColor = preset.sunColor; s.sunSize = preset.sunSize;
s.haze = preset.haze; s.mountains = !!preset.mountains; s.stars = !!preset.stars;
s.clouds = { ...(preset.clouds || { enabled: false }) };
s.fog = { enabled: preset.fog?.enabled !== false, ...(preset.fog || {}) };
s.light = preset.light || null;
this._applyLighting(preset.light, preset.sunDir);
} else {
// Ручной gradient: { topColor, bottomColor, horizonColor, sunDirection, sunColor, sunSize }
if (opts.topColor) s.top = opts.topColor;
if (opts.bottomColor) s.bottom = opts.bottomColor;
if (opts.horizonColor) s.horizon = opts.horizonColor;
if (opts.sunDirection) s.sunDir = [opts.sunDirection.x, opts.sunDirection.y, opts.sunDirection.z];
if (opts.sunColor) s.sunColor = opts.sunColor;
if (typeof opts.sunSize === 'number') s.sunSize = opts.sunSize;
if (typeof opts.haze === 'number') s.haze = opts.haze;
if (typeof opts.mountains === 'boolean') s.mountains = opts.mountains;
if (typeof opts.stars === 'boolean') s.stars = opts.stars;
}
this._rebuildAll();
}
/** Облака поверх любого режима. */
setClouds(opts) {
if (!opts) return;
this._state.clouds = { ...this._state.clouds, ...opts };
if (this._state.clouds.enabled == null) this._state.clouds.enabled = true;
this._buildClouds(this._state.clouds);
}
/** Атмосферный туман. */
setFog(opts) {
if (!opts) { return; }
this._state.fog = { ...this._state.fog, ...opts };
if (opts.enabled == null) this._state.fog.enabled = true;
this._applyFog(this._state.fog);
}
/** Установить направление солнца (для программной анимации). */
setSunDirection(dir) {
if (!dir) return;
this._state.sunDir = [dir.x, dir.y, dir.z];
this._applyShaderUniforms();
}
/** Плавный переход к пресету/опциям за durationSec секунд (цвета купола + туман). */
fadeTo(opts, durationSec = 2) {
const target = opts?.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null;
if (!target) { this.setSkybox(opts); return; }
// Запоминаем стартовые цвета и целевые — анимируем в tick().
this._fade = {
t: 0, dur: Math.max(0.1, durationSec),
from: {
top: hexToRgb(this._state.top), horizon: hexToRgb(this._state.horizon),
bottom: hexToRgb(this._state.bottom), sunColor: hexToRgb(this._state.sunColor),
sunDir: this._state.sunDir.slice(), sunSize: this._state.sunSize, haze: this._state.haze,
},
to: {
top: hexToRgb(target.top), horizon: hexToRgb(target.horizon),
bottom: hexToRgb(target.bottom), sunColor: hexToRgb(target.sunColor),
sunDir: target.sunDir.slice(), sunSize: target.sunSize, haze: target.haze,
},
target,
};
// Немедленно перестраиваем «дискретные» части (горы/звёзды/облака/туман
// целевого пресета появляются сразу, цвета купола — плавно).
const s = this._state;
s.mountains = !!target.mountains; s.stars = !!target.stars;
s.clouds = { ...(target.clouds || { enabled: false }) };
s.fog = { enabled: target.fog?.enabled !== false, ...(target.fog || {}) };
s.light = target.light || null;
this._rebuildExtras();
// Запоминаем стартовые/целевые значения света для плавной анимации.
if (target.light) {
this._fade.lightFrom = {
sunInt: this.sunLight?.intensity ?? 1,
hemiInt: this.hemiLight?.intensity ?? 0.7,
};
this._fade.lightTo = {
sunInt: target.light.sunIntensity ?? 1,
hemiInt: target.light.hemiIntensity ?? 0.7,
sunColor: target.light.sunColor, ambient: target.light.ambient,
};
}
}
/** Пересобрать всё (купол-uniforms + горы + звёзды + облака + туман + свет). */
_rebuildAll() {
this._applyShaderUniforms();
this._rebuildExtras();
this._applyLighting(this._state.light, this._state.sunDir);
}
_rebuildExtras() {
const s = this._state;
if (s.mountains) {
// Цвет гор подбираем под пресет (днём зеленовато-серый, ночью тёмный).
const mc = s.stars ? '#2a3550' : '#8fa98a';
this._buildMountains(mc);
} else this._disposeMountains();
this._buildStars(!!s.stars);
this._buildClouds(s.clouds);
this._applyFog(s.fog);
}
/** Вызывать каждый кадр: дрейф облаков + анимация fadeTo. */
tick(dt) {
// Дрейф облаков по кругу.
for (const c of this._clouds) {
c.mesh.position.x += c.speed * dt * 60;
if (c.mesh.position.x > 380) c.mesh.position.x = -380;
}
// Анимация перехода неба.
if (this._fade) {
this._fade.t += dt;
const k = Math.min(1, this._fade.t / this._fade.dur);
const f = this._fade.from, t = this._fade.to, m = this._mat;
const mix = (a, b) => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k];
if (m) {
m.setColor3('topColor', Color3.FromArray(mix(f.top, t.top)));
m.setColor3('bottomColor', Color3.FromArray(mix(f.bottom, t.bottom)));
m.setColor3('horizonColor', Color3.FromArray(mix(f.horizon, t.horizon)));
m.setColor3('sunColor', Color3.FromArray(mix(f.sunColor, t.sunColor)));
const sd = mix(f.sunDir, t.sunDir);
m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize());
m.setFloat('sunSize', f.sunSize + (t.sunSize - f.sunSize) * k);
m.setFloat('horizonHaze', f.haze + (t.haze - f.haze) * k);
// Плавно ведём направление солнца (свет) к целевому (используем sd выше).
if (this.sunLight) {
const d = new Vector3(-sd[0], -sd[1], -sd[2]);
if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; }
}
}
// Плавно ведём яркость/ambient света.
if (this._fade.lightFrom && this._fade.lightTo) {
const lf = this._fade.lightFrom, lt = this._fade.lightTo;
if (this.sunLight) {
this.sunLight.intensity = lf.sunInt + (lt.sunInt - lf.sunInt) * k;
if (lt.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(lt.sunColor));
}
if (this.hemiLight) {
this.hemiLight.intensity = lf.hemiInt + (lt.hemiInt - lf.hemiInt) * k;
if (lt.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(lt.ambient));
}
}
if (k >= 1) {
// Зафиксировать целевое состояние в _state (как hex).
const tp = this._fade.target;
Object.assign(this._state, {
top: tp.top, horizon: tp.horizon, bottom: tp.bottom,
sunColor: tp.sunColor, sunDir: tp.sunDir.slice(),
sunSize: tp.sunSize, haze: tp.haze,
});
this._fade = null;
}
}
}
serialize() {
return { ...this._state, _active: true };
}
load(data) {
if (!data) return;
this._state = { ...this._defaultState(), ...data };
this._rebuildAll();
}
dispose() {
this._disposeMountains();
this._disposeClouds();
this._disposeStars();
if (this._dome) { this._mat?.dispose(); this._dome.dispose(); this._dome = null; }
}
}

View File

@ -599,7 +599,6 @@ export class UserModelManager {
// instanceId — чтобы target-скрипты могли стабильно ссылаться
// на конкретный инстанс после перезагрузки.
instanceId: inst.instanceId,
...(inst.folderId != null ? { folderId: inst.folderId } : {}),
});
}
return arr;
@ -664,13 +663,7 @@ export class UserModelManager {
forceInstanceId: item.instanceId,
},
);
if (id != null) {
loaded++;
if (item.folderId != null) {
const inst = this.instances.get(id);
if (inst) inst.folderId = item.folderId;
}
}
if (id != null) loaded++;
} catch (e) {
console.warn('[UserModelManager] failed to load instance', item, e);
}

View File

@ -90,17 +90,6 @@ export class WeaponSystem {
if (e.button !== 0) return;
// Если UI-режим курсора — не стреляем (мышь работает по GUI)
if (this.scene3d?.player?.isUiCursorMode?.()) return;
// Свободный курсор (нет pointer-lock, обычно 3-е лицо) → стрелять туда,
// куда кликнули, а не в центр камеры.
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();
};
@ -108,26 +97,14 @@ export class WeaponSystem {
if (e.button !== 0) return;
this._mouseDown = false;
};
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)
@ -606,10 +583,7 @@ export class WeaponSystem {
// (для tap-to-shoot на мобиле). Точка применяется один раз.
let hit = null;
let ray;
let aim = this._aimScreenPoint;
if (!aim && this._holdAim && document.pointerLockElement !== this.scene3d?.canvas) {
aim = this._holdAim;
}
const aim = this._aimScreenPoint;
try {
if (aim) {
ray = this.scene.createPickingRay(aim.x, aim.y, null, camera);