feat(player): графика/эффекты (фича-парность со студией) + realtime на game.rublox.pro
GraphicsManager (постобработка/материалы/API game.graphics) — паритет со студией, применяется при загрузке игры если автор настроил. Новые материалы chrome/water/ iridescent. Realtime-эндпоинт переведён на game.rublox.pro (S1 NPM прямо, без hop через S2 — чинит разрывы WebSocket). MultiplayerSync улучшен. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f5a96fbec0
commit
ccf76d539b
@ -39,12 +39,12 @@ function exitPlayer(gameId) {
|
||||
// (флаг читает onBeforeUnload listener ниже).
|
||||
try { window.__rubloxExplicitExit = true; } catch {}
|
||||
const env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
|
||||
const RUBLOX_HOME = env.VITE_RUBLOX_HOME || 'https://rublox.pro/app';
|
||||
const RUBLOX_HOME = (env.VITE_RUBLOX_HOME || 'https://rublox.pro/app').replace(/\/+$/, '');
|
||||
if (gameId) {
|
||||
// Передаём gameId через ?game=<id> — главный сайт прочитает и снова
|
||||
// откроет карточку игры (юзер возвращается на ту же страницу).
|
||||
const sep = RUBLOX_HOME.includes('?') ? '&' : '?';
|
||||
window.location.assign(`${RUBLOX_HOME}${sep}game=${gameId}`);
|
||||
// У страницы игры теперь свой URL: /app/game/<id> (им можно делиться).
|
||||
// Возвращаемся прямо на него. base RUBLOX_HOME оканчивается на /app.
|
||||
const base = RUBLOX_HOME.endsWith('/app') ? RUBLOX_HOME : `${RUBLOX_HOME}/app`;
|
||||
window.location.assign(`${base}/game/${gameId}`);
|
||||
} else {
|
||||
window.location.assign(RUBLOX_HOME);
|
||||
}
|
||||
|
||||
@ -30,10 +30,13 @@ export const STORYS_addres = BASE + '/api-storys';
|
||||
// env-настроенные прямые URL.
|
||||
const IS_HTTPS = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
||||
|
||||
// 2026-06-05: realtime теперь прямо на game.rublox.pro (S1 NPM → S1 VM 110),
|
||||
// не через minecraftia-school.ru/api-game (лишний hop S2 NPM → S1 NAT
|
||||
// давал разрывы WebSocket каждую секунду).
|
||||
export const REALTIME_HTTP = ENV.VITE_REALTIME_HTTP
|
||||
?? (IS_HTTPS ? `${window.location.origin}/api-game` : 'http://localhost:8685');
|
||||
?? (IS_HTTPS ? 'https://game.rublox.pro' : 'http://localhost:8685');
|
||||
export const REALTIME_WS = ENV.VITE_REALTIME_WS
|
||||
?? (IS_HTTPS ? `wss://${window.location.host}/api-game` : 'ws://localhost:8685');
|
||||
?? (IS_HTTPS ? 'wss://game.rublox.pro' : 'ws://localhost:8685');
|
||||
|
||||
// Главный сайт Рублокса — куда редиректить юзеров без ticket/JWT.
|
||||
export const RUBLOX_HOME = ENV.VITE_RUBLOX_HOME ?? 'https://rublox.pro/app';
|
||||
|
||||
@ -96,6 +96,7 @@ import { GdForest } from './GdForest';
|
||||
import { GdPlayerCube } from './GdPlayerCube';
|
||||
import { GdPlayerTrail } from './GdPlayerTrail';
|
||||
import { GdPostFx } from './GdPostFx';
|
||||
import { GraphicsManager } from './GraphicsManager';
|
||||
import { PhysicsAABB } from './PhysicsAABB';
|
||||
import { PlayerController } from './PlayerController';
|
||||
import { SelectionManager } from './SelectionManager';
|
||||
@ -1649,6 +1650,42 @@ export class BabylonScene {
|
||||
this._ssaoEnabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Система графики/эффектов («шейдеры»). Лениво создаём GraphicsManager.
|
||||
* Идентична студийной (фича-парность). Применяется при загрузке игры,
|
||||
* если автор настроил graphics в проекте (и не 'off').
|
||||
*/
|
||||
_ensureGraphics() {
|
||||
if (this._graphics) {
|
||||
const cam = this.scene?.activeCamera || this.camera;
|
||||
if (cam) this._graphics.setCamera(cam);
|
||||
return this._graphics;
|
||||
}
|
||||
const cam = this.scene?.activeCamera || this.camera;
|
||||
if (!this.scene || !cam) return null;
|
||||
this._graphics = new GraphicsManager(this.scene, cam, this, {
|
||||
mobile: !!this._isMobileMode,
|
||||
});
|
||||
return this._graphics;
|
||||
}
|
||||
|
||||
setGraphics(settings) {
|
||||
const g = this._ensureGraphics();
|
||||
if (!g) return null;
|
||||
const cfg = g.apply(settings || {});
|
||||
this._graphicsConfig = cfg;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
getGraphicsState() {
|
||||
return this._graphics ? this._graphics.serialize() : (this._graphicsConfig || null);
|
||||
}
|
||||
|
||||
disableGraphics() {
|
||||
if (this._graphics) this._graphics.disableAll();
|
||||
this._graphicsConfig = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Включить/выключить SSAO пост-эффект (контактные тени).
|
||||
* Используем SSAORenderingPipeline v1 — v2 ломал thin-instance рендер
|
||||
@ -7611,6 +7648,11 @@ export class BabylonScene {
|
||||
if (state.scene.environment && this.environment) {
|
||||
this.environment.load(state.scene.environment);
|
||||
}
|
||||
// Графика/эффекты (шейдеры) — применяем если автор настроил и не 'off'.
|
||||
if (state.scene.graphics && state.scene.graphics.preset
|
||||
&& state.scene.graphics.preset !== 'off') {
|
||||
try { this.setGraphics(state.scene.graphics); } catch (e) { /* ignore */ }
|
||||
}
|
||||
// Кастомное небо (задача 16)
|
||||
if (state.scene.skybox && this.skybox) {
|
||||
this.skybox.load(state.scene.skybox);
|
||||
|
||||
@ -3331,6 +3331,10 @@ export class GameRuntime {
|
||||
} catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'graphics.set') {
|
||||
try { this.scene3d?.setGraphics?.(payload || {}); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
// === Задача 03: GUI tween ===
|
||||
if (cmd === 'gui.tween') {
|
||||
try {
|
||||
|
||||
328
src/engine/GraphicsManager.js
Normal file
328
src/engine/GraphicsManager.js
Normal file
@ -0,0 +1,328 @@
|
||||
/**
|
||||
* GraphicsManager — система визуальных эффектов («шейдеры») для игр Рублокса.
|
||||
*
|
||||
* Управляет:
|
||||
* - постобработкой экрана через Babylon DefaultRenderingPipeline:
|
||||
* bloom (свечение), FXAA (сглаживание), виньетка, цветокоррекция
|
||||
* (контраст/насыщенность/экспозиция), тонмаппинг, глубина резкости (DoF);
|
||||
* - качеством теней (через scene3d.setShadowQuality);
|
||||
* - контактными тенями SSAO (через scene3d.setSsaoEnabled).
|
||||
*
|
||||
* Управляется И из настроек игры (вкладка «Графика»), И из скриптов
|
||||
* (game.graphics.*). По умолчанию ВСЁ ВЫКЛЮЧЕНО (preset 'off') — старые игры
|
||||
* не меняются, FPS не страдает. Автор включает осознанно.
|
||||
*
|
||||
* Mobile-safe: на слабых устройствах тяжёлые эффекты (DoF, SSAO, ultra-тени,
|
||||
* HDR-bloom) автоматически урезаются, даже если в пресете включены.
|
||||
*
|
||||
* Один и тот же класс используется в студии и плеере (фича-парность).
|
||||
*
|
||||
* Использование:
|
||||
* const gfx = new GraphicsManager(scene, camera, scene3d, { mobile });
|
||||
* gfx.apply({ preset: 'cinematic' });
|
||||
* gfx.apply({ bloom: { enabled: true, intensity: 0.7 } });
|
||||
* gfx.dispose();
|
||||
*/
|
||||
import {
|
||||
DefaultRenderingPipeline, Color4, ImageProcessingConfiguration,
|
||||
} from '@babylonjs/core';
|
||||
|
||||
/**
|
||||
* Именованные пресеты. Каждый — полный набор настроек. 'off' = чистая картинка
|
||||
* (pipeline не создаётся вовсе). Значения подобраны так, чтобы быть заметными,
|
||||
* но не «кислотными».
|
||||
*/
|
||||
export const GRAPHICS_PRESETS = {
|
||||
off: {
|
||||
bloom: { enabled: false },
|
||||
fxaa: false,
|
||||
vignette: { enabled: false },
|
||||
grading: { enabled: false },
|
||||
dof: { enabled: false },
|
||||
ssao: false,
|
||||
shadows: null, // null = не трогаем текущее качество теней
|
||||
},
|
||||
// Лёгкий: только мягкое свечение + сглаживание. Дёшево, годится почти везде.
|
||||
low: {
|
||||
bloom: { enabled: true, intensity: 0.3, threshold: 0.9 },
|
||||
fxaa: true,
|
||||
vignette: { enabled: false },
|
||||
grading: { enabled: false },
|
||||
dof: { enabled: false },
|
||||
ssao: false,
|
||||
shadows: 'hard',
|
||||
},
|
||||
// Средний: свечение + лёгкая виньетка + чуть насыщенности.
|
||||
medium: {
|
||||
bloom: { enabled: true, intensity: 0.45, threshold: 0.85 },
|
||||
fxaa: true,
|
||||
vignette: { enabled: true, weight: 0.5 },
|
||||
grading: { enabled: true, contrast: 1.05, saturation: 1.1, exposure: 1.0 },
|
||||
dof: { enabled: false },
|
||||
ssao: false,
|
||||
shadows: 'soft',
|
||||
},
|
||||
// Высокий: всё кроме DoF, SSAO включён.
|
||||
high: {
|
||||
bloom: { enabled: true, intensity: 0.6, threshold: 0.82 },
|
||||
fxaa: true,
|
||||
vignette: { enabled: true, weight: 0.6 },
|
||||
grading: { enabled: true, contrast: 1.1, saturation: 1.2, exposure: 1.05 },
|
||||
dof: { enabled: false },
|
||||
ssao: true,
|
||||
shadows: 'soft',
|
||||
},
|
||||
// Ультра: + глубина резкости + мягкие каскадные тени.
|
||||
ultra: {
|
||||
bloom: { enabled: true, intensity: 0.7, threshold: 0.8 },
|
||||
fxaa: true,
|
||||
vignette: { enabled: true, weight: 0.65 },
|
||||
grading: { enabled: true, contrast: 1.12, saturation: 1.25, exposure: 1.05 },
|
||||
dof: { enabled: true, focusDistance: 18, focalLength: 50, aperture: 1.2 },
|
||||
ssao: true,
|
||||
shadows: 'high',
|
||||
},
|
||||
// === Стилевые пресеты (художественные) ===
|
||||
cinematic: {
|
||||
bloom: { enabled: true, intensity: 0.55, threshold: 0.8 },
|
||||
fxaa: true,
|
||||
vignette: { enabled: true, weight: 0.85 },
|
||||
grading: { enabled: true, contrast: 1.18, saturation: 1.05, exposure: 1.0 },
|
||||
dof: { enabled: true, focusDistance: 22, focalLength: 60, aperture: 1.0 },
|
||||
ssao: true,
|
||||
shadows: 'soft',
|
||||
},
|
||||
vivid: {
|
||||
bloom: { enabled: true, intensity: 0.65, threshold: 0.78 },
|
||||
fxaa: true,
|
||||
vignette: { enabled: false },
|
||||
grading: { enabled: true, contrast: 1.1, saturation: 1.5, exposure: 1.1 },
|
||||
dof: { enabled: false },
|
||||
ssao: false,
|
||||
shadows: 'soft',
|
||||
},
|
||||
night: {
|
||||
bloom: { enabled: true, intensity: 0.8, threshold: 0.7 },
|
||||
fxaa: true,
|
||||
vignette: { enabled: true, weight: 1.0 },
|
||||
grading: { enabled: true, contrast: 1.2, saturation: 0.85, exposure: 0.8 },
|
||||
dof: { enabled: false },
|
||||
ssao: true,
|
||||
shadows: 'soft',
|
||||
},
|
||||
retro: {
|
||||
bloom: { enabled: false },
|
||||
fxaa: false, // намеренно «пиксельно»
|
||||
vignette: { enabled: true, weight: 1.2 },
|
||||
grading: { enabled: true, contrast: 1.3, saturation: 0.7, exposure: 0.95 },
|
||||
dof: { enabled: false },
|
||||
ssao: false,
|
||||
shadows: 'hard',
|
||||
},
|
||||
soft: {
|
||||
bloom: { enabled: true, intensity: 0.4, threshold: 0.88 },
|
||||
fxaa: true,
|
||||
vignette: { enabled: true, weight: 0.4 },
|
||||
grading: { enabled: true, contrast: 0.95, saturation: 1.05, exposure: 1.05 },
|
||||
dof: { enabled: false },
|
||||
ssao: false,
|
||||
shadows: 'soft',
|
||||
},
|
||||
};
|
||||
|
||||
// Глубокое слияние пресета и пользовательских оверрайдов.
|
||||
function _mergeConfig(base, over) {
|
||||
const out = JSON.parse(JSON.stringify(base || {}));
|
||||
if (!over) return out;
|
||||
for (const k of Object.keys(over)) {
|
||||
const v = over[k];
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||||
out[k] = { ...(out[k] || {}), ...v };
|
||||
} else {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class GraphicsManager {
|
||||
/**
|
||||
* @param scene Babylon Scene
|
||||
* @param camera активная камера (для pipeline)
|
||||
* @param scene3d ссылка на BabylonScene (для setShadowQuality / setSsaoEnabled / света)
|
||||
* @param opts { mobile:boolean }
|
||||
*/
|
||||
constructor(scene, camera, scene3d, opts = {}) {
|
||||
this.scene = scene;
|
||||
this.camera = camera;
|
||||
this.scene3d = scene3d;
|
||||
this.mobile = !!opts.mobile;
|
||||
this._pipeline = null;
|
||||
// Текущая активная конфигурация (после merge + mobile-clamp).
|
||||
this.config = _mergeConfig(GRAPHICS_PRESETS.off, null);
|
||||
this.config.preset = 'off';
|
||||
this.enabled = false;
|
||||
}
|
||||
|
||||
/** Сменить камеру (например после смены режима камеры) — пересобрать pipeline. */
|
||||
setCamera(camera) {
|
||||
if (camera === this.camera) return;
|
||||
this.camera = camera;
|
||||
if (this.enabled) this._rebuildPipeline();
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить настройки графики. Принимает либо {preset}, либо отдельные
|
||||
* секции (bloom/vignette/grading/dof/ssao/fxaa/shadows), либо и то и другое
|
||||
* (оверрайды поверх пресета). Сохраняет состояние в this.config.
|
||||
*/
|
||||
apply(settings = {}) {
|
||||
let cfg;
|
||||
if (settings.preset && GRAPHICS_PRESETS[settings.preset]) {
|
||||
cfg = _mergeConfig(GRAPHICS_PRESETS[settings.preset], settings);
|
||||
cfg.preset = settings.preset;
|
||||
} else {
|
||||
// частичный апдейт поверх текущего
|
||||
cfg = _mergeConfig(this.config, settings);
|
||||
cfg.preset = settings.preset || this.config.preset || 'custom';
|
||||
}
|
||||
this.config = this._clampForMobile(cfg);
|
||||
this._applyConfig();
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/** Полностью выключить эффекты (как preset 'off'). */
|
||||
disableAll() {
|
||||
return this.apply({ preset: 'off' });
|
||||
}
|
||||
|
||||
/** Текущая конфигурация (для serialize). */
|
||||
serialize() {
|
||||
// Храним «как просили» (preset + явные оверрайды). Для простоты — весь cfg.
|
||||
return JSON.parse(JSON.stringify(this.config));
|
||||
}
|
||||
|
||||
// --- внутреннее ---
|
||||
|
||||
/** На слабых устройствах гасим самое дорогое, что бы ни просили. */
|
||||
_clampForMobile(cfg) {
|
||||
if (!this.mobile) return cfg;
|
||||
const c = JSON.parse(JSON.stringify(cfg));
|
||||
if (c.dof) c.dof.enabled = false; // DoF дорогой
|
||||
c.ssao = false; // SSAO дорогой
|
||||
if (c.shadows === 'high' || c.shadows === 'medium') c.shadows = 'hard';
|
||||
// bloom оставляем, но без HDR (решается в _rebuildPipeline)
|
||||
c._mobileClamped = true;
|
||||
return c;
|
||||
}
|
||||
|
||||
_applyConfig() {
|
||||
const c = this.config;
|
||||
const anyPipelineFx = (c.bloom && c.bloom.enabled) || c.fxaa
|
||||
|| (c.vignette && c.vignette.enabled) || (c.grading && c.grading.enabled)
|
||||
|| (c.dof && c.dof.enabled);
|
||||
|
||||
// Тени и SSAO — через scene3d (они вне pipeline).
|
||||
try {
|
||||
if (c.shadows && this.scene3d?.setShadowQuality) {
|
||||
this.scene3d.setShadowQuality(c.shadows);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
try {
|
||||
if (this.scene3d?.setSsaoEnabled) this.scene3d.setSsaoEnabled(!!c.ssao);
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
if (!anyPipelineFx) {
|
||||
this.enabled = false;
|
||||
this._disposePipeline();
|
||||
return;
|
||||
}
|
||||
this.enabled = true;
|
||||
this._rebuildPipeline();
|
||||
}
|
||||
|
||||
_rebuildPipeline() {
|
||||
this._disposePipeline();
|
||||
if (!this.scene || !this.camera) return;
|
||||
const c = this.config;
|
||||
const useHdr = (c.bloom && c.bloom.enabled) && !this.mobile;
|
||||
|
||||
const p = new DefaultRenderingPipeline('rbx_graphics', useHdr, this.scene, [this.camera]);
|
||||
|
||||
// Bloom
|
||||
p.bloomEnabled = !!(c.bloom && c.bloom.enabled);
|
||||
if (p.bloomEnabled) {
|
||||
p.bloomThreshold = c.bloom.threshold ?? 0.85;
|
||||
p.bloomWeight = c.bloom.intensity ?? 0.5;
|
||||
p.bloomKernel = this.mobile ? 32 : 64;
|
||||
p.bloomScale = 0.5;
|
||||
}
|
||||
|
||||
// FXAA
|
||||
p.fxaaEnabled = !!c.fxaa;
|
||||
p.samples = this.mobile ? 1 : 4;
|
||||
|
||||
// Image processing: виньетка + цветокоррекция + (опц.) тонмаппинг
|
||||
const ip = p.imageProcessing;
|
||||
if (ip) {
|
||||
p.imageProcessingEnabled = true;
|
||||
ip.toneMappingEnabled = false; // как в GdPostFx — иначе картинка темнеет
|
||||
// экспозиция/контраст из grading
|
||||
if (c.grading && c.grading.enabled) {
|
||||
ip.exposure = c.grading.exposure ?? 1.0;
|
||||
ip.contrast = c.grading.contrast ?? 1.0;
|
||||
ip.colorCurvesEnabled = true;
|
||||
try {
|
||||
const curves = ip.colorCurves;
|
||||
if (curves) {
|
||||
// saturation: 1.0 = норма → curves в диапазоне примерно -100..100
|
||||
const sat = c.grading.saturation ?? 1.0;
|
||||
curves.globalSaturation = Math.round((sat - 1.0) * 60);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
} else {
|
||||
ip.exposure = 1.0; ip.contrast = 1.0;
|
||||
}
|
||||
// виньетка
|
||||
if (c.vignette && c.vignette.enabled) {
|
||||
ip.vignetteEnabled = true;
|
||||
ip.vignetteWeight = c.vignette.weight ?? 0.6;
|
||||
ip.vignetteColor = new Color4(0, 0, 0, 0);
|
||||
ip.vignetteStretch = 0.3;
|
||||
ip.vignetteCameraFov = 0.5;
|
||||
ip.vignetteBlendMode = ImageProcessingConfiguration.VIGNETTEMODE_MULTIPLY;
|
||||
} else {
|
||||
ip.vignetteEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Depth of Field (глубина резкости) — только desktop
|
||||
if (c.dof && c.dof.enabled && !this.mobile) {
|
||||
p.depthOfFieldEnabled = true;
|
||||
try {
|
||||
p.depthOfFieldBlurLevel = 1; // 0..2
|
||||
p.depthOfField.focusDistance = (c.dof.focusDistance ?? 18) * 1000; // мм
|
||||
p.depthOfField.focalLength = c.dof.focalLength ?? 50;
|
||||
p.depthOfField.fStop = c.dof.aperture ?? 1.2;
|
||||
} catch (e) { /* ignore */ }
|
||||
} else {
|
||||
p.depthOfFieldEnabled = false;
|
||||
}
|
||||
|
||||
this._pipeline = p;
|
||||
}
|
||||
|
||||
_disposePipeline() {
|
||||
if (this._pipeline) {
|
||||
try { this._pipeline.dispose(); } catch (e) { /* ignore */ }
|
||||
this._pipeline = null;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._disposePipeline();
|
||||
this.scene = null;
|
||||
this.camera = null;
|
||||
this.scene3d = null;
|
||||
}
|
||||
}
|
||||
@ -137,9 +137,16 @@ export class MultiplayerSync {
|
||||
// 1. Подписки на state
|
||||
const $ = getStateCallbacks(this.room);
|
||||
|
||||
// Защита от повторного срабатывания onAdd (Colyseus 0.16 + immediate:true
|
||||
// может триггерить .onAdd на каждый schema patch). Локальный set хранит
|
||||
// sessionId которые уже обработаны в ТЕКУЩЕМ sync объекте.
|
||||
const _addedSessionIds = new Set();
|
||||
const handleAdd = (player, sessionId) => {
|
||||
// Не рисуем СЕБЯ как remote-меш — у нас уже есть PlayerController
|
||||
if (sessionId === this.room.sessionId) return;
|
||||
// Защита от дублирующих onAdd событий для уже добавленного игрока
|
||||
if (_addedSessionIds.has(sessionId)) return;
|
||||
_addedSessionIds.add(sessionId);
|
||||
this._addRemotePlayer(sessionId, player);
|
||||
// Подписываемся на изменения этого Player'а
|
||||
$(player).onChange(() => this._updateRemoteTarget(sessionId, player));
|
||||
@ -149,7 +156,11 @@ export class MultiplayerSync {
|
||||
this._attachRemoteWeapon(sessionId, val || '');
|
||||
});
|
||||
};
|
||||
// Используем тот же set в handleRemove чтобы при настоящем уходе игрока
|
||||
// потом можно было его снова добавить.
|
||||
this._addedSessionIds = _addedSessionIds;
|
||||
const handleRemove = (player, sessionId) => {
|
||||
if (this._addedSessionIds) this._addedSessionIds.delete(sessionId);
|
||||
this._removeRemotePlayer(sessionId);
|
||||
};
|
||||
|
||||
@ -289,8 +300,20 @@ export class MultiplayerSync {
|
||||
|
||||
// Интерполяция remote-игроков (позиция + yaw ставится на root,
|
||||
// модель — child root'а — следует за ним).
|
||||
// 2026-06-05: читаем target напрямую из room.state.players —
|
||||
// в Colyseus 0.16 onChange может не срабатывать для всех полей
|
||||
// (особенно yaw/animState), а target.x/y/z/yaw обновляется
|
||||
// через _updateRemoteTarget только из onChange. Подстраховка.
|
||||
for (const rp of this.remotePlayers.values()) {
|
||||
if (!rp.root || !rp.target) continue;
|
||||
const livePlayer = this.room?.state?.players?.get?.(rp.sessionId);
|
||||
if (livePlayer) {
|
||||
rp.target.x = livePlayer.x;
|
||||
rp.target.y = livePlayer.y;
|
||||
rp.target.z = livePlayer.z;
|
||||
rp.target.yaw = livePlayer.yaw || 0;
|
||||
if (livePlayer.animState) rp.animState = livePlayer.animState;
|
||||
}
|
||||
const cur = rp.current;
|
||||
cur.x += (rp.target.x - cur.x) * LERP_FACTOR;
|
||||
cur.y += (rp.target.y - cur.y) * LERP_FACTOR;
|
||||
@ -332,13 +355,25 @@ export class MultiplayerSync {
|
||||
// Развилка: R15-скины анимируются процедурно через R15Animator
|
||||
// (как локальный игрок), Kenney-модели — через glTF AnimationGroups.
|
||||
if (rp.isR15 && rp.r15Animator && rp.modelLoaded) {
|
||||
// Серверный animState: 'idle' | 'run' | 'attack'. R15Animator
|
||||
// понимает idle/walk/run/jump/fall. Сервер не различает
|
||||
// walk/run и не шлёт прыжки → маппим run→run, attack→idle
|
||||
// (атака показывается отдельным swing-ом руки ниже).
|
||||
const r15State = rp.isDead
|
||||
? 'idle'
|
||||
: (rp.animState === 'run' ? 'run' : 'idle');
|
||||
// Серверный animState: 'idle' | 'run' | 'jump' | 'fall' | 'attack'.
|
||||
// R15Animator понимает idle/walk/run/jump/fall.
|
||||
// 2026-06-05: раньше run/jump/fall маппились в idle (баг
|
||||
// в маппинге), из-за чего у remote-игроков не было
|
||||
// анимации ни ходьбы, ни прыжка. Теперь пробрасываем
|
||||
// напрямую. attack показывается отдельным swing руки.
|
||||
let r15State;
|
||||
if (rp.isDead) {
|
||||
r15State = 'idle';
|
||||
} else if (rp.animState === 'jump') {
|
||||
r15State = 'jump';
|
||||
} else if (rp.animState === 'fall') {
|
||||
r15State = 'fall';
|
||||
} else if (rp.animState === 'run') {
|
||||
r15State = 'run';
|
||||
} else {
|
||||
// 'attack' или 'idle' или неизвестное — стоим
|
||||
r15State = 'idle';
|
||||
}
|
||||
rp.r15Animator.setState(r15State);
|
||||
rp.r15Animator.update(dt);
|
||||
} else if (!rp.isR15) {
|
||||
@ -632,6 +667,23 @@ export class MultiplayerSync {
|
||||
// === Внутреннее: меши remote-игроков ===
|
||||
// =================================================================
|
||||
_addRemotePlayer(sessionId, player) {
|
||||
// Защита от дублей при Colyseus reconnect: state получается заново
|
||||
// и onAdd срабатывает для тех же sessionId. Без этой проверки в
|
||||
// сцене появляются клоны игроков (см. issue после 2026-06-05).
|
||||
if (this.remotePlayers && this.remotePlayers.has(sessionId)) {
|
||||
const existing = this.remotePlayers.get(sessionId);
|
||||
// Обновим target позицию и пометим что игрок жив
|
||||
const sx2 = player.x || 0, sy2 = player.y || 0, sz2 = player.z || 0, yaw2 = player.yaw || 0;
|
||||
existing.target = { x: sx2, y: sy2, z: sz2, yaw: yaw2 };
|
||||
existing.username = player.username || sessionId;
|
||||
existing.modelType = player.modelType || existing.modelType;
|
||||
existing.hp = player.hp ?? existing.hp;
|
||||
existing.maxHp = player.maxHp ?? existing.maxHp;
|
||||
existing.isDead = !!player.isDead;
|
||||
existing.animState = player.animState || existing.animState;
|
||||
console.log(`[MultiplayerSync] re-add (reconnect): ${sessionId} (${player.username}) — обновили без пересоздания меша`);
|
||||
return;
|
||||
}
|
||||
const sx = player.x || 0;
|
||||
const sy = player.y || 0;
|
||||
const sz = player.z || 0;
|
||||
|
||||
@ -485,12 +485,40 @@ export class PrimitiveManager {
|
||||
break;
|
||||
case 'glass':
|
||||
mat.alpha = 0.4;
|
||||
mat.specularColor = new Color3(0.5, 0.5, 0.5);
|
||||
mat.specularColor = new Color3(0.8, 0.85, 0.9);
|
||||
mat.specularPower = 96;
|
||||
mat.backFaceCulling = false;
|
||||
break;
|
||||
case 'neon':
|
||||
mat.emissiveColor = Color3.FromHexString(color || '#888888');
|
||||
mat.specularColor = new Color3(0, 0, 0);
|
||||
break;
|
||||
case 'chrome': {
|
||||
const cc = Color3.FromHexString(color || '#cfd6e0');
|
||||
mat.diffuseColor = new Color3(cc.r * 0.6, cc.g * 0.6, cc.b * 0.6);
|
||||
mat.specularColor = new Color3(1, 1, 1);
|
||||
mat.specularPower = 128;
|
||||
mat.emissiveColor = new Color3(cc.r * 0.12, cc.g * 0.12, cc.b * 0.14);
|
||||
break;
|
||||
}
|
||||
case 'water': {
|
||||
const wc = Color3.FromHexString(color || '#3aa0ff');
|
||||
mat.diffuseColor = wc;
|
||||
mat.alpha = 0.55;
|
||||
mat.specularColor = new Color3(0.9, 0.95, 1.0);
|
||||
mat.specularPower = 64;
|
||||
mat.emissiveColor = new Color3(wc.r * 0.1, wc.g * 0.14, wc.b * 0.2);
|
||||
mesh._isWater = true;
|
||||
break;
|
||||
}
|
||||
case 'iridescent': {
|
||||
const ic = Color3.FromHexString(color || '#a06bff');
|
||||
mat.diffuseColor = ic;
|
||||
mat.emissiveColor = new Color3(ic.r * 0.5, ic.g * 0.35, ic.b * 0.6);
|
||||
mat.specularColor = new Color3(1, 1, 1);
|
||||
mat.specularPower = 96;
|
||||
break;
|
||||
}
|
||||
case 'studs': {
|
||||
// Лего-материал (паритет со студией): почти-белая diffuse × цвет
|
||||
// меша + normal map. emissive = доля цвета → сочность (Roblox-look).
|
||||
|
||||
@ -3377,6 +3377,41 @@ const game = {
|
||||
_send('environment.setTimeOfDay', { hours: h });
|
||||
},
|
||||
},
|
||||
/**
|
||||
* graphics — визуальные эффекты («шейдеры»): постобработка, свечение,
|
||||
* цветокоррекция, тени. По умолчанию всё выключено.
|
||||
*/
|
||||
graphics: {
|
||||
setPreset(preset) {
|
||||
if (typeof preset !== 'string') return;
|
||||
_send('graphics.set', { preset });
|
||||
},
|
||||
set(settings) {
|
||||
if (typeof settings !== 'object' || !settings) return;
|
||||
_send('graphics.set', settings);
|
||||
},
|
||||
setBloom(on, opts) {
|
||||
_send('graphics.set', { bloom: { enabled: !!on, ...(opts || {}) } });
|
||||
},
|
||||
setVignette(weight) {
|
||||
const w = Number(weight) || 0;
|
||||
_send('graphics.set', { vignette: { enabled: w > 0, weight: w } });
|
||||
},
|
||||
setColorGrading(opts) {
|
||||
if (typeof opts !== 'object' || !opts) return;
|
||||
_send('graphics.set', { grading: { enabled: true, ...opts } });
|
||||
},
|
||||
setAntialiasing(on) { _send('graphics.set', { fxaa: !!on }); },
|
||||
setDepthOfField(on, opts) {
|
||||
_send('graphics.set', { dof: { enabled: !!on, ...(opts || {}) } });
|
||||
},
|
||||
setShadows(quality) {
|
||||
if (typeof quality !== 'string') return;
|
||||
_send('graphics.set', { shadows: quality });
|
||||
},
|
||||
setSSAO(on) { _send('graphics.set', { ssao: !!on }); },
|
||||
off() { _send('graphics.set', { preset: 'off' }); },
|
||||
},
|
||||
/**
|
||||
* Управление режимами ввода — курсор и камера.
|
||||
* В режиме 'ui' мышь работает как обычный курсор (как в браузере),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user