Open-source web player for Rublox games, dual-licensed under AGPL-3.0 + Commercial. Highlights: - Babylon.js 7 + React 18 + Vite 5 stack - Self-contained engine (~46k lines): BlockManager, ModelManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD gamemodes - Configurable backend via VITE_API_BASE and friends — works against staging (dev-api.rublox.pro) out of the box - Standalone mode (VITE_STANDALONE=true) loads a bundled sample game for first-run without any backend - Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - Lint + format scaffolding (ESLint + Prettier + EditorConfig) - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Removed before public release: - frontend_deploy.py (contained production SSH credentials) - ~27 admin endpoints (kept in private repo) - Hard-coded internal URLs and IPs - All previous git history (clean repo init)
272 lines
12 KiB
JavaScript
272 lines
12 KiB
JavaScript
/**
|
||
* Environment — управление окружением сцены: время суток, скайбокс, освещение,
|
||
* туман. Работает поверх существующих lights/clearColor BabylonScene.
|
||
*
|
||
* Время суток (timeOfDay): 0..24 часов.
|
||
* 06:00 — рассвет (золотистый свет, оранжево-розовый горизонт)
|
||
* 12:00 — день (яркое солнце, голубое небо)
|
||
* 18:00 — закат (красно-оранжевое небо, длинные тени)
|
||
* 00:00 — ночь (тёмно-синее небо, луна, приглушённый свет)
|
||
*
|
||
* Пресеты:
|
||
* 'day' — фиксированный день (12:00)
|
||
* 'sunset' — закат (18:30)
|
||
* 'night' — ночь (00:00)
|
||
* 'dawn' — рассвет (06:00)
|
||
* 'cycle' — авто-цикл (timeSpeed контролирует скорость)
|
||
*
|
||
* Public API:
|
||
* setPreset(preset)
|
||
* setTimeOfDay(hour) — 0..24
|
||
* setTimeSpeed(seconds_per_hour) — для cycle-режима
|
||
* tick(dt) — вызывать в render-loop
|
||
* serialize() / load(data)
|
||
*/
|
||
import {
|
||
Color3, Color4, Vector3,
|
||
MeshBuilder, StandardMaterial,
|
||
} from '@babylonjs/core';
|
||
|
||
/**
|
||
* Цвета по часам — интерполируем между ключевыми моментами.
|
||
* Каждая запись: [час, sky, fog, sunDir, sunIntensity, sunColor, ambientGround, hemiIntensity]
|
||
*/
|
||
const KEYFRAMES = [
|
||
// hour, sky color (RGB 0..1), sun direction, sunIntensity, sunColor, hemiGround, hemiIntensity
|
||
{ hour: 0, sky: [0.05, 0.05, 0.15], sunDir: [-0.3, -1, 0.2], sunInt: 0.05, sunCol: [0.4, 0.5, 0.7], hemiGround: [0.05, 0.05, 0.1], hemiInt: 0.15 },
|
||
{ hour: 5, sky: [0.15, 0.12, 0.25], sunDir: [-0.6, -0.5, 0.3], sunInt: 0.15, sunCol: [0.6, 0.4, 0.4], hemiGround: [0.1, 0.08, 0.15], hemiInt: 0.25 },
|
||
{ hour: 7, sky: [0.95, 0.7, 0.5], sunDir: [-0.8, -0.4, 0.2], sunInt: 0.6, sunCol: [1.0, 0.8, 0.5], hemiGround: [0.3, 0.25, 0.3], hemiInt: 0.5 },
|
||
{ hour: 12, sky: [0.5, 0.7, 0.95], sunDir: [-0.3, -1, -0.2], sunInt: 0.9, sunCol: [1.0, 1.0, 0.95], hemiGround: [0.3, 0.3, 0.4], hemiInt: 0.65 },
|
||
{ hour: 17, sky: [0.6, 0.75, 0.9], sunDir: [0.4, -0.6, -0.3], sunInt: 0.7, sunCol: [1.0, 0.9, 0.7], hemiGround: [0.3, 0.3, 0.35], hemiInt: 0.55 },
|
||
{ hour: 19, sky: [0.95, 0.55, 0.4], sunDir: [0.7, -0.3, -0.2], sunInt: 0.5, sunCol: [1.0, 0.6, 0.4], hemiGround: [0.35, 0.25, 0.25], hemiInt: 0.4 },
|
||
{ hour: 21, sky: [0.2, 0.18, 0.35], sunDir: [0.5, -0.6, 0.2], sunInt: 0.15, sunCol: [0.5, 0.5, 0.7], hemiGround: [0.1, 0.1, 0.2], hemiInt: 0.2 },
|
||
{ hour: 24, sky: [0.05, 0.05, 0.15], sunDir: [-0.3, -1, 0.2], sunInt: 0.05, sunCol: [0.4, 0.5, 0.7], hemiGround: [0.05, 0.05, 0.1], hemiInt: 0.15 },
|
||
];
|
||
|
||
const PRESETS = {
|
||
day: { hour: 12, autoCycle: false },
|
||
sunset: { hour: 18.5, autoCycle: false },
|
||
night: { hour: 0, autoCycle: false },
|
||
dawn: { hour: 6, autoCycle: false },
|
||
cycle: { hour: 12, autoCycle: true },
|
||
};
|
||
|
||
function lerp(a, b, t) { return a + (b - a) * t; }
|
||
function lerpVec3(a, b, t) {
|
||
return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
|
||
}
|
||
|
||
/** Найти кадры до и после текущего часа и интерполировать. */
|
||
function sampleAt(hour) {
|
||
let prev = KEYFRAMES[0], next = KEYFRAMES[KEYFRAMES.length - 1];
|
||
for (let i = 0; i < KEYFRAMES.length - 1; i++) {
|
||
if (KEYFRAMES[i].hour <= hour && KEYFRAMES[i + 1].hour >= hour) {
|
||
prev = KEYFRAMES[i]; next = KEYFRAMES[i + 1]; break;
|
||
}
|
||
}
|
||
const span = next.hour - prev.hour;
|
||
const t = span > 0 ? (hour - prev.hour) / span : 0;
|
||
return {
|
||
sky: lerpVec3(prev.sky, next.sky, t),
|
||
sunDir: lerpVec3(prev.sunDir, next.sunDir, t),
|
||
sunInt: lerp(prev.sunInt, next.sunInt, t),
|
||
sunCol: lerpVec3(prev.sunCol, next.sunCol, t),
|
||
hemiGround: lerpVec3(prev.hemiGround, next.hemiGround, t),
|
||
hemiInt: lerp(prev.hemiInt, next.hemiInt, t),
|
||
};
|
||
}
|
||
|
||
export class Environment {
|
||
constructor(scene, hemiLight, sunLight) {
|
||
this.scene = scene;
|
||
this.hemiLight = hemiLight;
|
||
this.sunLight = sunLight;
|
||
this.preset = 'day';
|
||
this.timeOfDay = 12; // часов 0..24
|
||
// Длительность фаз цикла day/night в МИНУТАХ реального времени.
|
||
// День — с 06 до 18 (12 игровых часов).
|
||
// Ночь — с 18 до 06 (тоже 12 игровых часов).
|
||
this.dayDurationMin = 5;
|
||
this.nightDurationMin = 3;
|
||
this.fogEnabled = false;
|
||
this.fogColor = [0.7, 0.8, 0.9];
|
||
this.fogDensity = 0.01;
|
||
// Видимые тела на небе (солнце и луна) — создаём по запросу
|
||
this._sunMesh = null;
|
||
this._moonMesh = null;
|
||
this._createSkyBodies();
|
||
this._applyTime();
|
||
}
|
||
|
||
_createSkyBodies() {
|
||
// Солнце — жёлтая светящаяся сфера
|
||
const sun = MeshBuilder.CreateSphere('skySun', { diameter: 8, segments: 12 }, this.scene);
|
||
const sunMat = new StandardMaterial('skySunMat', this.scene);
|
||
sunMat.diffuseColor = new Color3(0, 0, 0);
|
||
sunMat.emissiveColor = new Color3(1, 0.95, 0.7);
|
||
sunMat.specularColor = new Color3(0, 0, 0);
|
||
sunMat.disableLighting = true;
|
||
sun.material = sunMat;
|
||
sun.isPickable = false;
|
||
sun.renderingGroupId = 0;
|
||
this._sunMesh = sun;
|
||
|
||
// Луна — серовато-белая сфера
|
||
const moon = MeshBuilder.CreateSphere('skyMoon', { diameter: 6, segments: 12 }, this.scene);
|
||
const moonMat = new StandardMaterial('skyMoonMat', this.scene);
|
||
moonMat.diffuseColor = new Color3(0, 0, 0);
|
||
moonMat.emissiveColor = new Color3(0.85, 0.85, 0.95);
|
||
moonMat.specularColor = new Color3(0, 0, 0);
|
||
moonMat.disableLighting = true;
|
||
moon.material = moonMat;
|
||
moon.isPickable = false;
|
||
moon.renderingGroupId = 0;
|
||
this._moonMesh = moon;
|
||
}
|
||
|
||
/** Вычислить позицию небесного тела по фазе 0..1 (0=восход, 0.5=зенит, 1=закат). */
|
||
_skyBodyPosition(phase) {
|
||
// Дуга по полусфере радиусом R, центр в (0, 0, 0).
|
||
// phase=0 → восток (восход на горизонте);
|
||
// phase=0.5 → зенит;
|
||
// phase=1 → запад (заход).
|
||
const R = 80;
|
||
const angle = phase * Math.PI; // 0..π
|
||
return new Vector3(
|
||
Math.cos(angle) * R, // восход +X → закат -X
|
||
Math.sin(angle) * R, // 0 → R → 0
|
||
0
|
||
);
|
||
}
|
||
|
||
setPreset(preset) {
|
||
if (!PRESETS[preset]) return;
|
||
this.preset = preset;
|
||
const cfg = PRESETS[preset];
|
||
this.timeOfDay = cfg.hour;
|
||
this._applyTime();
|
||
}
|
||
|
||
setTimeOfDay(hour) {
|
||
this.timeOfDay = ((hour % 24) + 24) % 24;
|
||
this._applyTime();
|
||
}
|
||
|
||
/** Установить длительность фазы дня и ночи в минутах реального времени. */
|
||
setCycleDuration(dayMin, nightMin) {
|
||
if (typeof dayMin === 'number' && dayMin > 0) this.dayDurationMin = dayMin;
|
||
if (typeof nightMin === 'number' && nightMin > 0) this.nightDurationMin = nightMin;
|
||
}
|
||
|
||
/** Совместимость со старым setTimeSpeed (sec/hour). Игнорируется в новом API. */
|
||
setTimeSpeed() { /* deprecated */ }
|
||
|
||
setFog(enabled, color, density) {
|
||
this.fogEnabled = !!enabled;
|
||
if (Array.isArray(color)) this.fogColor = color;
|
||
if (density != null) this.fogDensity = density;
|
||
this._applyFog();
|
||
}
|
||
|
||
/** В render-loop вызвать каждый кадр (для авто-цикла). */
|
||
tick(dt) {
|
||
if (this.preset !== 'cycle') return;
|
||
// День: 06 → 18 (12 игровых часов) идёт за dayDurationMin минут реального.
|
||
// Ночь: 18 → 06 (12 игровых часов) идёт за nightDurationMin минут реального.
|
||
const isDayTime = this.timeOfDay >= 6 && this.timeOfDay < 18;
|
||
const phaseSeconds = (isDayTime ? this.dayDurationMin : this.nightDurationMin) * 60;
|
||
// За phaseSeconds игровое время продвигается на 12 часов.
|
||
const hoursPerSecond = 12 / phaseSeconds;
|
||
this.timeOfDay = (this.timeOfDay + dt * hoursPerSecond) % 24;
|
||
this._applyTime();
|
||
}
|
||
|
||
/** Применить текущее время суток к небу и освещению. */
|
||
_applyTime() {
|
||
const s = sampleAt(this.timeOfDay);
|
||
if (this.scene) {
|
||
this.scene.clearColor = new Color4(s.sky[0], s.sky[1], s.sky[2], 1.0);
|
||
}
|
||
if (this.sunLight) {
|
||
// Babylon DirectionalLight: direction указывает В сторону куда падает свет.
|
||
const dir = new Vector3(s.sunDir[0], s.sunDir[1], s.sunDir[2]);
|
||
dir.normalize();
|
||
this.sunLight.direction = dir;
|
||
this.sunLight.intensity = s.sunInt;
|
||
this.sunLight.diffuse = new Color3(s.sunCol[0], s.sunCol[1], s.sunCol[2]);
|
||
}
|
||
if (this.hemiLight) {
|
||
this.hemiLight.intensity = s.hemiInt;
|
||
this.hemiLight.groundColor = new Color3(s.hemiGround[0], s.hemiGround[1], s.hemiGround[2]);
|
||
this.hemiLight.diffuse = new Color3(s.sky[0], s.sky[1], s.sky[2]);
|
||
}
|
||
|
||
// === Позиции солнца и луны ===
|
||
// Солнце видно с 06 до 18 (12 часов). phase = (hour - 6) / 12 → 0..1
|
||
// Луна видна с 18 до 06 (через полночь). phase = ((hour + 6) % 24) / 12 → 0..1
|
||
const h = this.timeOfDay;
|
||
if (this._sunMesh) {
|
||
if (h >= 6 && h <= 18) {
|
||
const phase = (h - 6) / 12;
|
||
this._sunMesh.position = this._skyBodyPosition(phase);
|
||
this._sunMesh.setEnabled(true);
|
||
} else {
|
||
this._sunMesh.setEnabled(false);
|
||
}
|
||
}
|
||
if (this._moonMesh) {
|
||
// Луна видна когда солнце под горизонтом
|
||
if (h <= 6 || h >= 18) {
|
||
// hour 18→0 = phase 0 (восход), hour 0→6 = phase 0.5..1
|
||
const adj = (h + 6) % 24;
|
||
const phase = adj / 12;
|
||
this._moonMesh.position = this._skyBodyPosition(phase);
|
||
this._moonMesh.setEnabled(true);
|
||
} else {
|
||
this._moonMesh.setEnabled(false);
|
||
}
|
||
}
|
||
|
||
this._applyFog();
|
||
}
|
||
|
||
_applyFog() {
|
||
if (!this.scene) return;
|
||
if (this.fogEnabled) {
|
||
this.scene.fogMode = 2; // FOGMODE_EXP
|
||
this.scene.fogColor = new Color3(this.fogColor[0], this.fogColor[1], this.fogColor[2]);
|
||
this.scene.fogDensity = this.fogDensity;
|
||
} else {
|
||
this.scene.fogMode = 0; // FOGMODE_NONE
|
||
}
|
||
}
|
||
|
||
serialize() {
|
||
return {
|
||
preset: this.preset,
|
||
timeOfDay: this.timeOfDay,
|
||
dayDurationMin: this.dayDurationMin,
|
||
nightDurationMin: this.nightDurationMin,
|
||
fogEnabled: this.fogEnabled,
|
||
fogColor: this.fogColor,
|
||
fogDensity: this.fogDensity,
|
||
};
|
||
}
|
||
|
||
load(data) {
|
||
if (!data) return;
|
||
if (data.preset) this.preset = data.preset;
|
||
if (typeof data.timeOfDay === 'number') this.timeOfDay = data.timeOfDay;
|
||
if (typeof data.dayDurationMin === 'number' && data.dayDurationMin > 0) {
|
||
this.dayDurationMin = data.dayDurationMin;
|
||
}
|
||
if (typeof data.nightDurationMin === 'number' && data.nightDurationMin > 0) {
|
||
this.nightDurationMin = data.nightDurationMin;
|
||
}
|
||
if (typeof data.fogEnabled === 'boolean') this.fogEnabled = data.fogEnabled;
|
||
if (Array.isArray(data.fogColor)) this.fogColor = data.fogColor;
|
||
if (typeof data.fogDensity === 'number') this.fogDensity = data.fogDensity;
|
||
this._applyTime();
|
||
}
|
||
}
|