feat(09): сочные круглые studs (v4) + color-пикер окрашиваемых блоков
- Текстура studs v4: круглые кружки с усиленным объёмом (normal strength 4.0, запечённый блик/тень) + контактная тень от каждого кружка. Фон 0.97 — цвет остаётся сочным. emissive 45% от цвета на примитивах (Roblox-look). - Версионные имена файлов (studs_v4_*) — обход browser-кэша Babylon. - Color-пикер блоков: в палитре при выборе окрашиваемого блока (studs-block) под категориями появляется ряд из 8 лего-цветов + input «свой цвет». BabylonScene.setActiveBlockColor → addBlock(...,color) при постановке. - DEV-хук ?dev=<имя> (localhost): грузит /dev-<имя>.json в редактор для локального теста без БД (на проде неактивен). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
65aa26996d
commit
7ab66fc4c5
1
public/dev-lego-polygon.json
Normal file
1
public/dev-lego-polygon.json
Normal file
File diff suppressed because one or more lines are too long
@ -772,6 +772,8 @@ const KubikonEditor = () => {
|
|||||||
const [gizmoMode, setGizmoMode] = useState('select'); // 'select'|'move'|'rotate'|'scale'
|
const [gizmoMode, setGizmoMode] = useState('select'); // 'select'|'move'|'rotate'|'scale'
|
||||||
const [snapStep, setSnapStep] = useState(1.0);
|
const [snapStep, setSnapStep] = useState(1.0);
|
||||||
const [blockCategory, setBlockCategory] = useState(BLOCK_CATEGORIES[0]);
|
const [blockCategory, setBlockCategory] = useState(BLOCK_CATEGORIES[0]);
|
||||||
|
// Цвет для окрашиваемых блоков (studs-block, задача 09).
|
||||||
|
const [activeBlockColor, setActiveBlockColor] = useState('#3a7aff');
|
||||||
const [modelCategory, setModelCategory] = useState(MODEL_CATEGORIES[0]);
|
const [modelCategory, setModelCategory] = useState(MODEL_CATEGORIES[0]);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
@ -1284,6 +1286,27 @@ const KubikonEditor = () => {
|
|||||||
setActiveTool(prev => prev === 'model' ? 'model' : 'select');
|
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
|
// Для нового проекта — создаём шаблон папок workspace
|
||||||
if (id === 'new') {
|
if (id === 'new') {
|
||||||
try {
|
try {
|
||||||
@ -1342,7 +1365,8 @@ const KubikonEditor = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Если редактируем существующий проект — грузим из БД.
|
// Если редактируем существующий проект — грузим из БД.
|
||||||
if (id !== 'new' && /^\d+$/.test(id)) {
|
// ПРОПУСКАЕМ при активном DEV-хуке (?dev=) — там сцена грузится из файла.
|
||||||
|
if (id !== 'new' && /^\d+$/.test(id) && !_devParam) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
// Передаём userId — иначе сервер не определит owner для
|
// Передаём userId — иначе сервер не определит owner для
|
||||||
@ -1578,6 +1602,11 @@ const KubikonEditor = () => {
|
|||||||
if (sceneRef.current) sceneRef.current.setActiveBlockType(activeBlockType);
|
if (sceneRef.current) sceneRef.current.setActiveBlockType(activeBlockType);
|
||||||
}, [activeBlockType]);
|
}, [activeBlockType]);
|
||||||
|
|
||||||
|
// Цвет окрашиваемого блока → в сцену (для studs-block при постановке).
|
||||||
|
useEffect(() => {
|
||||||
|
if (sceneRef.current) sceneRef.current.setActiveBlockColor?.(activeBlockColor);
|
||||||
|
}, [activeBlockColor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sceneRef.current) sceneRef.current.setActiveModelType(activeModelType);
|
if (sceneRef.current) sceneRef.current.setActiveModelType(activeModelType);
|
||||||
}, [activeModelType]);
|
}, [activeModelType]);
|
||||||
@ -2250,6 +2279,34 @@ const KubikonEditor = () => {
|
|||||||
</div>
|
</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' ? (
|
{paletteTab === 'blocks' ? (
|
||||||
<div className={cl.blocksGrid}>
|
<div className={cl.blocksGrid}>
|
||||||
|
|||||||
@ -3374,7 +3374,7 @@ export class BabylonScene {
|
|||||||
// _computePlacementCell вернёт нецелый y (реальная высота
|
// _computePlacementCell вернёт нецелый y (реальная высота
|
||||||
// поверхности) — округляем, чтобы блок встал ровно в клетку.
|
// поверхности) — округляем, чтобы блок встал ровно в клетку.
|
||||||
const by = Math.round(target.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}`;
|
this._lastPlacedKey = `${target.x},${by},${target.z}`;
|
||||||
// Авто-выделение поставленного блока. Тени уже работают через proto-меш
|
// Авто-выделение поставленного блока. Тени уже работают через proto-меш
|
||||||
// (зарегистрирован в refreshAllShadows и обновляется автоматически).
|
// (зарегистрирован в refreshAllShadows и обновляется автоматически).
|
||||||
@ -3506,7 +3506,7 @@ export class BabylonScene {
|
|||||||
const key = `${target.x},${target.y},${target.z}`;
|
const key = `${target.x},${target.y},${target.z}`;
|
||||||
if (key === this._lastPlacedKey) return;
|
if (key === this._lastPlacedKey) return;
|
||||||
if (this.blockManager.hasBlock(target.x, target.y, target.z)) 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;
|
this._lastPlacedKey = key;
|
||||||
} else if (tool === 'erase') {
|
} else if (tool === 'erase') {
|
||||||
if (pick.mesh?.metadata?.isBlock) {
|
if (pick.mesh?.metadata?.isBlock) {
|
||||||
@ -4876,6 +4876,11 @@ export class BabylonScene {
|
|||||||
this._activeBlockType = blockTypeId;
|
this._activeBlockType = blockTypeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Цвет для постановки окрашиваемых блоков (studs-block). null = дефолт типа. */
|
||||||
|
setActiveBlockColor(hex) {
|
||||||
|
this._activeBlockColor = hex || null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Публичный сеттер: выбрать тип модели для постановки. */
|
/** Публичный сеттер: выбрать тип модели для постановки. */
|
||||||
setActiveModelType(modelTypeId) {
|
setActiveModelType(modelTypeId) {
|
||||||
this._activeModelType = modelTypeId;
|
this._activeModelType = modelTypeId;
|
||||||
|
|||||||
@ -373,8 +373,10 @@ export class BlockManager {
|
|||||||
if (blockType.normal) {
|
if (blockType.normal) {
|
||||||
try { mat.bumpTexture = new Texture(blockType.normal, this.scene); } catch (e) {}
|
try { mat.bumpTexture = new Texture(blockType.normal, this.scene); } catch (e) {}
|
||||||
}
|
}
|
||||||
mat.specularColor = new Color3(0.2, 0.2, 0.2);
|
// Сочность (Roblox-look): почти-белая текстура × яркий vertex color,
|
||||||
mat.specularPower = 24;
|
// specular убран (он белит/тускнит цвет). vertex color (RGBA из
|
||||||
|
// thin-instance color buffer) умножается на diffuse → цвет насыщенный.
|
||||||
|
mat.specularColor = new Color3(0, 0, 0);
|
||||||
mat.useVertexColors = true;
|
mat.useVertexColors = true;
|
||||||
return mat;
|
return mat;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -111,8 +111,8 @@ export const BLOCK_TYPES = [
|
|||||||
// ThinInstance умножается на серую studs-текстуру). colorable:true говорит
|
// ThinInstance умножается на серую studs-текстуру). colorable:true говорит
|
||||||
// палитре показать color-пикер, BlockManager — включить color buffer.
|
// палитре показать color-пикер, BlockManager — включить color buffer.
|
||||||
cube('studs-block', 'Лего-кирпич', 'Окрашиваемые',
|
cube('studs-block', 'Лего-кирпич', 'Окрашиваемые',
|
||||||
'/kubikon-assets/materials/studs_diffuse.png',
|
'/kubikon-assets/materials/studs_v4_diffuse.png',
|
||||||
{ colorable: true, normal: '/kubikon-assets/materials/studs_normal.png', defaultColor: '#3a7aff' }),
|
{ colorable: true, normal: '/kubikon-assets/materials/studs_v4_normal.png', defaultColor: '#3a7aff' }),
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Все доступные категории в порядке появления. */
|
/** Все доступные категории в порядке появления. */
|
||||||
|
|||||||
@ -2573,6 +2573,10 @@ export class GameRuntime {
|
|||||||
if (data.material === 'neon') {
|
if (data.material === 'neon') {
|
||||||
data.mesh.material.emissiveColor = c;
|
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) {
|
} catch (e) {
|
||||||
|
|||||||
@ -28,10 +28,10 @@ import { getPrimitiveType } from './PrimitiveTypes';
|
|||||||
// Серая diffuse-текстура с сеткой выпуклых кружков (multiply на цвет меша) +
|
// Серая diffuse-текстура с сеткой выпуклых кружков (multiply на цвет меша) +
|
||||||
// normal map для иллюзии выпуклости. 1 stud = STUD_UNIT юнитов → тайлинг
|
// normal map для иллюзии выпуклости. 1 stud = STUD_UNIT юнитов → тайлинг
|
||||||
// считается из реального размера меша (куб 4×4 покажет 4×4 кружка).
|
// считается из реального размера меша (куб 4×4 покажет 4×4 кружка).
|
||||||
const STUDS_DIFFUSE_URL = '/kubikon-assets/materials/studs_diffuse.png';
|
const STUDS_DIFFUSE_URL = '/kubikon-assets/materials/studs_v4_diffuse.png';
|
||||||
const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_normal.png';
|
const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_v4_normal.png';
|
||||||
const STUD_UNIT = 1; // 1 кружок на 1 юнит размера
|
const STUD_UNIT = 1; // 1 круглый stud на 1 юнит размера
|
||||||
const STUDS_GRID = 4; // текстура содержит сетку 4×4 studs
|
const STUDS_GRID = 4; // текстура содержит сетку 4×4 круглых studs
|
||||||
// Кэш текстур на сцену: чтобы не грузить один и тот же PNG для каждого меша.
|
// Кэш текстур на сцену: чтобы не грузить один и тот же PNG для каждого меша.
|
||||||
// Map<scene, { diffuse: Texture, normal: Texture }>. Каждый меш получает свою
|
// Map<scene, { diffuse: Texture, normal: Texture }>. Каждый меш получает свою
|
||||||
// материал-копию (свой цвет/тайлинг), но текстуры шарятся.
|
// материал-копию (свой цвет/тайлинг), но текстуры шарятся.
|
||||||
@ -483,11 +483,10 @@ export class PrimitiveManager {
|
|||||||
mat.specularColor = new Color3(0, 0, 0);
|
mat.specularColor = new Color3(0, 0, 0);
|
||||||
break;
|
break;
|
||||||
case 'studs': {
|
case 'studs': {
|
||||||
// Лего-материал: серая diffuse-текстура с кружками умножается на
|
// Лего-материал: почти белая diffuse-текстура с лёгкими кружками
|
||||||
// цвет меша (StandardMaterial.diffuseTexture * diffuseColor), normal
|
// умножается на цвет меша → цвет остаётся СОЧНЫМ (как в Roblox).
|
||||||
// map даёт выпуклость. Тайлинг — по реальному размеру меша.
|
// emissive = доля цвета → цвет «светится», не тускнеет в тени.
|
||||||
const tex = _getStudsTextures(this.scene);
|
const tex = _getStudsTextures(this.scene);
|
||||||
// Клон-обёртки текстур: uScale/vScale per-mesh, сама картинка шарится.
|
|
||||||
const dt = tex.diffuse.clone();
|
const dt = tex.diffuse.clone();
|
||||||
const nt = tex.normal.clone();
|
const nt = tex.normal.clone();
|
||||||
const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
|
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;
|
dt.vScale = nt.vScale = tile.v;
|
||||||
mat.diffuseTexture = dt;
|
mat.diffuseTexture = dt;
|
||||||
mat.bumpTexture = nt;
|
mat.bumpTexture = nt;
|
||||||
// Цвет меша остаётся как multiply-tint поверх серой текстуры.
|
const sc = Color3.FromHexString(color || '#cccccc');
|
||||||
mat.diffuseColor = Color3.FromHexString(color || '#cccccc');
|
mat.diffuseColor = sc;
|
||||||
mat.specularColor = new Color3(0.25, 0.25, 0.25);
|
// Сочность: подмешиваем цвет в emissive (45%) — Roblox-look,
|
||||||
mat.specularPower = 24;
|
// насыщенный даже без прямого света. 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;
|
break;
|
||||||
}
|
}
|
||||||
case 'matte':
|
case 'matte':
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user