feat(09): Studs материал + окрашиваемые блоки + лего-сет #20

Merged
min merged 5 commits from feat/studs-material-09 into main 2026-05-31 11:17:07 +00:00
7 changed files with 89 additions and 19 deletions
Showing only changes of commit 7ab66fc4c5 - Show all commits

File diff suppressed because one or more lines are too long

View File

@ -772,6 +772,8 @@ const KubikonEditor = () => {
const [gizmoMode, setGizmoMode] = useState('select'); // 'select'|'move'|'rotate'|'scale'
const [snapStep, setSnapStep] = useState(1.0);
const [blockCategory, setBlockCategory] = useState(BLOCK_CATEGORIES[0]);
// Цвет для окрашиваемых блоков (studs-block, задача 09).
const [activeBlockColor, setActiveBlockColor] = useState('#3a7aff');
const [modelCategory, setModelCategory] = useState(MODEL_CATEGORIES[0]);
const [search, setSearch] = useState('');
@ -1284,6 +1286,27 @@ const KubikonEditor = () => {
setActiveTool(prev => prev === 'model' ? 'model' : 'select');
});
// DEV-хук (только localhost): грузим готовую тест-сцену через ?dev=<имя>
// fetch /dev-<имя>.json из public/. Работает на ЛЮБОМ id (включая
// существующий проект dev-сцена заменяет его, БД-загрузка пропускается),
// чтобы не упираться в модал «Новая игра». Позволяет тестировать локально
// без БД (S2/прод). На проде неактивен.
const _isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const _devParam = _isLocalhost ? new URLSearchParams(window.location.search).get('dev') : null;
if (_isLocalhost && _devParam) {
fetch('/dev-' + _devParam + '.json')
.then(r => (r.ok ? r.json() : null))
.then(async (parsed) => {
if (!parsed) return;
try {
await sceneRef.current.loadFromState(parsed);
setProjectName('DEV: ' + (parsed.__devName || 'тест-сцена'));
dirtyRef.current = false;
} catch (e) { console.warn('[DEV scene load] failed', e); }
})
.catch(() => {});
}
// Для нового проекта создаём шаблон папок workspace
if (id === 'new') {
try {
@ -1342,7 +1365,8 @@ const KubikonEditor = () => {
}
// Если редактируем существующий проект грузим из БД.
if (id !== 'new' && /^\d+$/.test(id)) {
// ПРОПУСКАЕМ при активном DEV-хуке (?dev=) там сцена грузится из файла.
if (id !== 'new' && /^\d+$/.test(id) && !_devParam) {
(async () => {
try {
// Передаём userId иначе сервер не определит owner для
@ -1578,6 +1602,11 @@ const KubikonEditor = () => {
if (sceneRef.current) sceneRef.current.setActiveBlockType(activeBlockType);
}, [activeBlockType]);
// Цвет окрашиваемого блока в сцену (для studs-block при постановке).
useEffect(() => {
if (sceneRef.current) sceneRef.current.setActiveBlockColor?.(activeBlockColor);
}, [activeBlockColor]);
useEffect(() => {
if (sceneRef.current) sceneRef.current.setActiveModelType(activeModelType);
}, [activeModelType]);
@ -2250,6 +2279,34 @@ const KubikonEditor = () => {
</div>
)}
{/* Color-пикер для окрашиваемых блоков (studs-block, задача 09).
Показываем когда активный блок имеет colorable:true. */}
{paletteTab === 'blocks'
&& BLOCK_TYPES.find(b => b.id === activeBlockType)?.colorable && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 4px', flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, opacity: 0.8 }}>Цвет блока:</span>
{['#e02a2a', '#e07a30', '#f0c020', '#5cba35', '#3a7aff', '#9b5cf0', '#ffffff', '#222428'].map(c => (
<button
key={c}
onClick={() => setActiveBlockColor(c)}
title={c}
style={{
width: 22, height: 22, borderRadius: 5, background: c, cursor: 'pointer',
border: activeBlockColor === c ? '2px solid #fff' : '1px solid rgba(255,255,255,0.25)',
boxShadow: activeBlockColor === c ? '0 0 0 1px var(--accent)' : 'none',
}}
/>
))}
<input
type="color"
value={activeBlockColor}
onChange={e => setActiveBlockColor(e.target.value)}
title="Свой цвет"
style={{ width: 28, height: 24, padding: 0, border: 'none', background: 'none', cursor: 'pointer' }}
/>
</div>
)}
{/* Сетка блоков или моделей в зависимости от вкладки */}
{paletteTab === 'blocks' ? (
<div className={cl.blocksGrid}>

View File

@ -3374,7 +3374,7 @@ export class BabylonScene {
// _computePlacementCell вернёт нецелый y (реальная высота
// поверхности) — округляем, чтобы блок встал ровно в клетку.
const by = Math.round(target.y);
const mesh = this.blockManager.addBlock(target.x, by, target.z, this._activeBlockType);
const mesh = this.blockManager.addBlock(target.x, by, target.z, this._activeBlockType, this._activeBlockColor);
this._lastPlacedKey = `${target.x},${by},${target.z}`;
// Авто-выделение поставленного блока. Тени уже работают через proto-меш
// (зарегистрирован в refreshAllShadows и обновляется автоматически).
@ -3506,7 +3506,7 @@ export class BabylonScene {
const key = `${target.x},${target.y},${target.z}`;
if (key === this._lastPlacedKey) return;
if (this.blockManager.hasBlock(target.x, target.y, target.z)) return;
this.blockManager.addBlock(target.x, target.y, target.z, this._activeBlockType);
this.blockManager.addBlock(target.x, target.y, target.z, this._activeBlockType, this._activeBlockColor);
this._lastPlacedKey = key;
} else if (tool === 'erase') {
if (pick.mesh?.metadata?.isBlock) {
@ -4876,6 +4876,11 @@ export class BabylonScene {
this._activeBlockType = blockTypeId;
}
/** Цвет для постановки окрашиваемых блоков (studs-block). null = дефолт типа. */
setActiveBlockColor(hex) {
this._activeBlockColor = hex || null;
}
/** Публичный сеттер: выбрать тип модели для постановки. */
setActiveModelType(modelTypeId) {
this._activeModelType = modelTypeId;

View File

@ -373,8 +373,10 @@ export class BlockManager {
if (blockType.normal) {
try { mat.bumpTexture = new Texture(blockType.normal, this.scene); } catch (e) {}
}
mat.specularColor = new Color3(0.2, 0.2, 0.2);
mat.specularPower = 24;
// Сочность (Roblox-look): почти-белая текстура × яркий vertex color,
// specular убран (он белит/тускнит цвет). vertex color (RGBA из
// thin-instance color buffer) умножается на diffuse → цвет насыщенный.
mat.specularColor = new Color3(0, 0, 0);
mat.useVertexColors = true;
return mat;
}

View File

@ -111,8 +111,8 @@ export const BLOCK_TYPES = [
// ThinInstance умножается на серую studs-текстуру). colorable:true говорит
// палитре показать color-пикер, BlockManager — включить color buffer.
cube('studs-block', 'Лего-кирпич', 'Окрашиваемые',
'/kubikon-assets/materials/studs_diffuse.png',
{ colorable: true, normal: '/kubikon-assets/materials/studs_normal.png', defaultColor: '#3a7aff' }),
'/kubikon-assets/materials/studs_v4_diffuse.png',
{ colorable: true, normal: '/kubikon-assets/materials/studs_v4_normal.png', defaultColor: '#3a7aff' }),
];
/** Все доступные категории в порядке появления. */

View File

@ -2573,6 +2573,10 @@ export class GameRuntime {
if (data.material === 'neon') {
data.mesh.material.emissiveColor = c;
}
// studs — emissive = доля цвета (сочность сохраняется при смене).
if (data.material === 'studs') {
data.mesh.material.emissiveColor = new Color3(c.r * 0.45, c.g * 0.45, c.b * 0.45);
}
}
}
} catch (e) {

View File

@ -28,10 +28,10 @@ import { getPrimitiveType } from './PrimitiveTypes';
// Серая diffuse-текстура с сеткой выпуклых кружков (multiply на цвет меша) +
// normal map для иллюзии выпуклости. 1 stud = STUD_UNIT юнитов → тайлинг
// считается из реального размера меша (куб 4×4 покажет 4×4 кружка).
const STUDS_DIFFUSE_URL = '/kubikon-assets/materials/studs_diffuse.png';
const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_normal.png';
const STUD_UNIT = 1; // 1 кружок на 1 юнит размера
const STUDS_GRID = 4; // текстура содержит сетку 4×4 studs
const STUDS_DIFFUSE_URL = '/kubikon-assets/materials/studs_v4_diffuse.png';
const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_v4_normal.png';
const STUD_UNIT = 1; // 1 круглый stud на 1 юнит размера
const STUDS_GRID = 4; // текстура содержит сетку 4×4 круглых studs
// Кэш текстур на сцену: чтобы не грузить один и тот же PNG для каждого меша.
// Map<scene, { diffuse: Texture, normal: Texture }>. Каждый меш получает свою
// материал-копию (свой цвет/тайлинг), но текстуры шарятся.
@ -483,11 +483,10 @@ export class PrimitiveManager {
mat.specularColor = new Color3(0, 0, 0);
break;
case 'studs': {
// Лего-материал: серая diffuse-текстура с кружками умножается на
// цвет меша (StandardMaterial.diffuseTexture * diffuseColor), normal
// map даёт выпуклость. Тайлинг — по реальному размеру меша.
// Лего-материал: почти белая diffuse-текстура с лёгкими кружками
// умножается на цвет меша → цвет остаётся СОЧНЫМ (как в Roblox).
// emissive = доля цвета → цвет «светится», не тускнеет в тени.
const tex = _getStudsTextures(this.scene);
// Клон-обёртки текстур: uScale/vScale per-mesh, сама картинка шарится.
const dt = tex.diffuse.clone();
const nt = tex.normal.clone();
const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
@ -496,10 +495,12 @@ export class PrimitiveManager {
dt.vScale = nt.vScale = tile.v;
mat.diffuseTexture = dt;
mat.bumpTexture = nt;
// Цвет меша остаётся как multiply-tint поверх серой текстуры.
mat.diffuseColor = Color3.FromHexString(color || '#cccccc');
mat.specularColor = new Color3(0.25, 0.25, 0.25);
mat.specularPower = 24;
const sc = Color3.FromHexString(color || '#cccccc');
mat.diffuseColor = sc;
// Сочность: подмешиваем цвет в emissive (45%) — Roblox-look,
// насыщенный даже без прямого света. specular убираем (он белит).
mat.emissiveColor = new Color3(sc.r * 0.45, sc.g * 0.45, sc.b * 0.45);
mat.specularColor = new Color3(0, 0, 0);
break;
}
case 'matte':