Compare commits
No commits in common. "4144fb47cc792c2b052138c687d4bd2c0201ab8e" and "5d1f515f42accb7e2b150ff443006f59f8d0f7a2" have entirely different histories.
4144fb47cc
...
5d1f515f42
@ -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
41
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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 => ({ '&': '&', '<': '<', '>': '>' }[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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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 => ({ '&': '&', '<': '<', '>': '>' }[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; }
|
||||
}
|
||||
}
|
||||
@ -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 => ({ '&': '&', '<': '<', '>': '>' }[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 = '';
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 */ }
|
||||
}
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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 — играют один раз,
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user