feat(09): Studs материал + окрашиваемые блоки + лего-сет #20
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
1
public/dev-studstest.json
Normal file
1
public/dev-studstest.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"version": 1, "scene": {"blocks": [], "models": [], "primitives": [{"id": 1, "type": "cube", "x": 0, "y": 1, "z": 0, "sx": 10, "sy": 1, "sz": 1, "rotationX": 0, "rotationY": 0, "rotationZ": 0, "color": "#e8e8e8", "material": "studs", "canCollide": true, "visible": true, "anchored": true, "mass": 1, "name": "Длинный брус", "folderId": null}, {"id": 2, "type": "cube", "x": 0, "y": 1, "z": 4, "sx": 2, "sy": 2, "sz": 2, "rotationX": 0, "rotationY": 0, "rotationZ": 0, "color": "#3a7aff", "material": "studs", "canCollide": true, "visible": true, "anchored": true, "mass": 1, "name": "Куб 2x2", "folderId": null}, {"id": 3, "type": "cube", "x": 6, "y": 1, "z": 2, "sx": 1, "sy": 1, "sz": 6, "rotationX": 0, "rotationY": 0, "rotationZ": 0, "color": "#e07a30", "material": "studs", "canCollide": true, "visible": true, "anchored": true, "mass": 1, "name": "Брус по Z", "folderId": null}], "userModels": [], "terrain": null, "robloxTerrain": null, "decorations": [], "folders": [], "gui": [], "inventory": [], "spawnPoint": {"x": 0, "y": 1.7, "z": -6}, "playerModelType": "character-a", "worldSize": 100, "floorEnabled": true, "scripts": []}, "editorCamera": null, "settings": {}, "__devName": "Studs-тест"}
|
||||||
@ -323,4 +323,9 @@ export const GAMES = [
|
|||||||
desc: '3D-стрелка-указатель «иди сюда»: дорожка из бегущих шевронов + парящий маркер над целью. Дошёл — стрелка прыгает на следующую.',
|
desc: '3D-стрелка-указатель «иди сюда»: дорожка из бегущих шевронов + парящий маркер над целью. Дошёл — стрелка прыгает на следующую.',
|
||||||
mechanics: ['game.fx.pointer', 'preset стрелки', 'setTarget/update', 'onTouch цели'],
|
mechanics: ['game.fx.pointer', 'preset стрелки', 'setTarget/update', 'onTouch цели'],
|
||||||
previewShot: 'guide-strelka-scene.png', openProjectId: 333, ready: true },
|
previewShot: 'guide-strelka-scene.png', openProjectId: 333, ready: true },
|
||||||
|
{ id: 'guide-lego', num: 56, group: 'g5', stars: 1, icon: 'cube',
|
||||||
|
title: 'Лего-полигон — studs материал',
|
||||||
|
desc: 'Лего-кружки (studs) на блоках и примитивах любого цвета: зелёный пол, оранжевая стена, разноцветные кубы + готовый лего-сет (дерево, дом, машина).',
|
||||||
|
mechanics: ['material: studs', 'studs-block (цвет на блок)', 'тайлинг по размеру', 'лего-сет моделей'],
|
||||||
|
previewShot: 'guide-lego-scene.png', openProjectId: 0, ready: true },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -7900,6 +7900,88 @@ game.after(0.4, () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'guide-lego': {
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<h3 className="lessonH">Что получится</h3>
|
||||||
|
<p>
|
||||||
|
Лего-полигон в стиле Roblox: <b>зелёный пол</b> и <b>оранжевая
|
||||||
|
стена</b> из окрашиваемых блоков, <b>жёлтая ступенька</b>, синий
|
||||||
|
куб и фиолетовые кубики — все с узнаваемой текстурой
|
||||||
|
<b> «studs»</b> (лего-кружки). В углу — готовый <b>лего-сет</b>:
|
||||||
|
деревья, дом и машина. Один и тот же материал, любой цвет — как
|
||||||
|
настоящий конструктор.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Shot src="guide-lego-play.png" wide
|
||||||
|
caption="Лего-полигон в игре: пол и стена из studs-блоков, разноцветные кубы, готовые лего-дерево/дом/машина в углу." />
|
||||||
|
|
||||||
|
<h3 className="lessonH">Чему научишься</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>material: 'studs'</b> — лего-текстура на любом примитиве
|
||||||
|
(куб, сфера, цилиндр), цвет берётся из обычного color;</li>
|
||||||
|
<li><b>studs-block</b> — окрашиваемый блок: один тип, цвет
|
||||||
|
задаётся на каждый блок (per-instance), тысячи блоков — один
|
||||||
|
draw call;</li>
|
||||||
|
<li><b>тайлинг по размеру</b> — кружки не растягиваются: куб 4×4
|
||||||
|
покажет 4×4 studs, куб 1×1 — один;</li>
|
||||||
|
<li><b>лего-сет</b> — готовые модели (кирпичи, плиты, скаты,
|
||||||
|
дерево, дом, машина, человечек) из studs-примитивов.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 1. Пол и стена из studs-блоков</h3>
|
||||||
|
<Shot src="guide-lego-scene.png" wide
|
||||||
|
caption="Сцена в редакторе: зелёный пол и оранжевая стена из окрашиваемых блоков, разноцветные studs-кубы, лего-модели в углу." />
|
||||||
|
<Step n="1">
|
||||||
|
В палитре блоков открой категорию <b>«Окрашиваемые»</b> — там
|
||||||
|
блок <kbd className="kbd">Лего-кирпич</kbd> (studs-block). Под
|
||||||
|
палитрой появится <b>выбор цвета</b>.
|
||||||
|
</Step>
|
||||||
|
<Step n="2">
|
||||||
|
Поставь зелёным цветом большой <b>пол</b> (30×30), затем оранжевым —
|
||||||
|
<b> стену</b> вдоль дальнего края. Цвет каждого блока сохраняется
|
||||||
|
отдельно — стена и пол из одного типа блока.
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 2. Studs на примитивах</h3>
|
||||||
|
<Step n="3">
|
||||||
|
Поставь <b>куб</b> (Примитив → cube). В инспекторе справа выбери
|
||||||
|
материал <b>«Studs»</b> (пятый рядом с Матовый/Металл/Стекло/Неон)
|
||||||
|
и задай цвет. Готово — лего-кружки на всех гранях.
|
||||||
|
</Step>
|
||||||
|
<Note>
|
||||||
|
Размер кружков считается автоматически от размера меша: растяни
|
||||||
|
куб — studs останутся того же масштаба, просто их станет больше.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
<h3 className="lessonH">Шаг 3. Через код</h3>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`// Окрашиваемый блок — пол:
|
||||||
|
for (let x = -15; x < 15; x++)
|
||||||
|
for (let z = -15; z < 15; z++)
|
||||||
|
game.scene.spawn('block:studs-block', { x, y: 0, z, color: '#5cba35' });
|
||||||
|
|
||||||
|
// Примитив с лего-текстурой:
|
||||||
|
game.scene.spawn('primitive:cube', {
|
||||||
|
x: 0, y: 1, z: 0, sx: 2, sy: 2, sz: 2,
|
||||||
|
color: '#3a7aff', material: 'studs',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Готовая модель из лего-сета:
|
||||||
|
game.scene.spawn('model:lego-house-small', { x: 10, y: 0, z: -10 });
|
||||||
|
|
||||||
|
// Сменить цвет блока на лету:
|
||||||
|
game.scene.setColor('block:0,0,0', '#ff0000');`}</Code>
|
||||||
|
|
||||||
|
<Try>
|
||||||
|
Собери из <b>лего-кирпичей</b> разных размеров (1×2, 2×4, 2×8) свою
|
||||||
|
постройку, покрась каждый ряд в свой цвет — и поставь сверху
|
||||||
|
готовую <b>лего-машину</b> из набора.
|
||||||
|
</Try>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Есть ли готовый текст урока для игры с таким id. */
|
/** Есть ли готовый текст урока для игры с таким id. */
|
||||||
|
|||||||
@ -267,6 +267,7 @@ const PRIMITIVE_MATERIALS = [
|
|||||||
{ id: 'metal', name: 'Металл' },
|
{ id: 'metal', name: 'Металл' },
|
||||||
{ id: 'glass', name: 'Стекло' },
|
{ id: 'glass', name: 'Стекло' },
|
||||||
{ id: 'neon', name: 'Неон' },
|
{ id: 'neon', name: 'Неон' },
|
||||||
|
{ id: 'studs', name: 'Studs' }, // задача 09 — лего-кружки (любой цвет)
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Форматирование массы — без хвоста из десятичных. Целые без запятой. */
|
/** Форматирование массы — без хвоста из десятичных. Целые без запятой. */
|
||||||
@ -316,6 +317,7 @@ const InspectorPanel = ({
|
|||||||
const [localSz, setLocalSz] = useState('');
|
const [localSz, setLocalSz] = useState('');
|
||||||
const [localColor, setLocalColor] = useState('#888888');
|
const [localColor, setLocalColor] = useState('#888888');
|
||||||
const [localMaterial, setLocalMaterial] = useState('matte');
|
const [localMaterial, setLocalMaterial] = useState('matte');
|
||||||
|
const [localStudDensity, setLocalStudDensity] = useState(1); // плотность studs
|
||||||
const [localCanCollide, setLocalCanCollide] = useState(true);
|
const [localCanCollide, setLocalCanCollide] = useState(true);
|
||||||
const [localVisible, setLocalVisible] = useState(true);
|
const [localVisible, setLocalVisible] = useState(true);
|
||||||
const [localAnchored, setLocalAnchored] = useState(true);
|
const [localAnchored, setLocalAnchored] = useState(true);
|
||||||
@ -361,6 +363,7 @@ const InspectorPanel = ({
|
|||||||
setLocalSz((selection.sz || 1).toFixed(2));
|
setLocalSz((selection.sz || 1).toFixed(2));
|
||||||
setLocalColor(selection.color || '#888888');
|
setLocalColor(selection.color || '#888888');
|
||||||
setLocalMaterial(selection.material || 'matte');
|
setLocalMaterial(selection.material || 'matte');
|
||||||
|
setLocalStudDensity(selection.studDensity || 1);
|
||||||
setLocalCanCollide(selection.canCollide !== false);
|
setLocalCanCollide(selection.canCollide !== false);
|
||||||
setLocalVisible(selection.visible !== false);
|
setLocalVisible(selection.visible !== false);
|
||||||
setLocalAnchored(selection.anchored !== false);
|
setLocalAnchored(selection.anchored !== false);
|
||||||
@ -1754,6 +1757,38 @@ const InspectorPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Размер studs — плотность лего-кружков (только для material studs) */}
|
||||||
|
{localMaterial === 'studs' && (
|
||||||
|
<div className={cl.section}>
|
||||||
|
<div className={cl.sectionTitle}><Icon name="grid" size={12} /> Размер studs</div>
|
||||||
|
<div className={cl.row3}>
|
||||||
|
{[
|
||||||
|
{ label: 'Крупные', d: 0.5 },
|
||||||
|
{ label: 'Средние', d: 1 },
|
||||||
|
{ label: 'Мелкие', d: 2 },
|
||||||
|
{ label: 'Меньше', d: 4 },
|
||||||
|
].map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.d}
|
||||||
|
type="button"
|
||||||
|
className={cl.smallBtn}
|
||||||
|
onClick={() => {
|
||||||
|
setLocalStudDensity(opt.d);
|
||||||
|
onSetPrimitiveProps?.({ studDensity: opt.d });
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontWeight: localStudDensity === opt.d ? 700 : 400,
|
||||||
|
background: localStudDensity === opt.d ? 'var(--accent)' : undefined,
|
||||||
|
color: localStudDensity === opt.d ? '#fff' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Текстура — своя картинка на гранях примитива */}
|
{/* Текстура — своя картинка на гранях примитива */}
|
||||||
<div className={cl.section}>
|
<div className={cl.section}>
|
||||||
<div className={cl.sectionTitle}>
|
<div className={cl.sectionTitle}>
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -1332,6 +1332,9 @@ export class BabylonScene {
|
|||||||
|
|
||||||
// При окончании drag — синхронизируем
|
// При окончании drag — синхронизируем
|
||||||
this._gizmo.setOnDragEnd(() => this._onGizmoDragEnd());
|
this._gizmo.setOnDragEnd(() => this._onGizmoDragEnd());
|
||||||
|
// Во время scale-drag — live-обновление тайлинга studs (кружки одного
|
||||||
|
// размера, не растягиваются пока тянешь гизмо).
|
||||||
|
this._gizmo.setOnDrag((mode) => { if (mode === 'scale') this._onGizmoScaleDrag(); });
|
||||||
|
|
||||||
// Привязка гизмо к выделенному
|
// Привязка гизмо к выделенному
|
||||||
this.selection.setOnSelectionChange((sel) => this._updateGizmoForSelection(sel));
|
this.selection.setOnSelectionChange((sel) => this._updateGizmoForSelection(sel));
|
||||||
@ -3427,7 +3430,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 и обновляется автоматически).
|
||||||
@ -3559,7 +3562,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) {
|
||||||
@ -3600,6 +3603,25 @@ export class BabylonScene {
|
|||||||
* Гизмо манипулировал объектом — синхронизируем через SelectionManager.
|
* Гизмо манипулировал объектом — синхронизируем через SelectionManager.
|
||||||
* Тип операции (move/rotate/scale) определяется по режиму гизмо.
|
* Тип операции (move/rotate/scale) определяется по режиму гизмо.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Во время scale-drag studs-примитива: текстура (faceUV) растягивается
|
||||||
|
* вместе с mesh.scaling и кружки превращаются в полосы. Чтобы не показывать
|
||||||
|
* это — временно прячем diffuse/bump-текстуру (плоский цвет). В dragEnd меш
|
||||||
|
* пересоздаётся с правильным faceUV и текстура возвращается.
|
||||||
|
*/
|
||||||
|
_onGizmoScaleDrag() {
|
||||||
|
if (!this.selection) return;
|
||||||
|
const sel = this.selection.getSelection?.();
|
||||||
|
if (!sel || sel.type !== 'primitive' || sel.material !== 'studs') return;
|
||||||
|
const mat = sel.mesh?.material;
|
||||||
|
if (mat && mat.diffuseTexture && !mat._studsHidden) {
|
||||||
|
mat._studsStash = { diffuse: mat.diffuseTexture, bump: mat.bumpTexture };
|
||||||
|
mat.diffuseTexture = null;
|
||||||
|
mat.bumpTexture = null;
|
||||||
|
mat._studsHidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_onGizmoDragEnd() {
|
_onGizmoDragEnd() {
|
||||||
if (!this.selection || !this._gizmo) return;
|
if (!this.selection || !this._gizmo) return;
|
||||||
const sel = this.selection.getSelection();
|
const sel = this.selection.getSelection();
|
||||||
@ -4929,6 +4951,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;
|
||||||
|
|||||||
@ -94,6 +94,10 @@ export class BlockManager {
|
|||||||
this._lavaSurfaceBaseY = null;
|
this._lavaSurfaceBaseY = null;
|
||||||
this._lavaDirty = false;
|
this._lavaDirty = false;
|
||||||
this._animTime = 0;
|
this._animTime = 0;
|
||||||
|
// Окрашиваемые блоки (studs-block, задача 09): per-instance color через
|
||||||
|
// ThinInstance color buffer. blockTypeId → Float32Array(maxBlocks*4 RGBA).
|
||||||
|
this._colorsByProto = new Map();
|
||||||
|
this._STUDS_MAX = 20000; // макс блоков одного окрашиваемого типа
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Вызывать каждый кадр для анимации воды/лавы. */
|
/** Вызывать каждый кадр для анимации воды/лавы. */
|
||||||
@ -359,6 +363,24 @@ export class BlockManager {
|
|||||||
const mat = new StandardMaterial(name, this.scene);
|
const mat = new StandardMaterial(name, this.scene);
|
||||||
mat.specularColor = new Color3(0, 0, 0);
|
mat.specularColor = new Color3(0, 0, 0);
|
||||||
|
|
||||||
|
// Окрашиваемый блок (studs-block): цвет берётся per-instance из vertex
|
||||||
|
// color буфера ThinInstance и умножается на серую текстуру. Включаем
|
||||||
|
// useVertexColors, normal map (выпуклость кружков), мягкий спекуляр.
|
||||||
|
if (blockType.colorable) {
|
||||||
|
const tex = new Texture(texturePath, this.scene);
|
||||||
|
mat.diffuseTexture = tex;
|
||||||
|
mat.diffuseColor = new Color3(1, 1, 1); // нейтраль — цвет идёт из vertex color
|
||||||
|
if (blockType.normal) {
|
||||||
|
try { mat.bumpTexture = new Texture(blockType.normal, this.scene); } catch (e) {}
|
||||||
|
}
|
||||||
|
// Сочность (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;
|
||||||
|
}
|
||||||
|
|
||||||
if (texturePath) {
|
if (texturePath) {
|
||||||
const tex = new Texture(texturePath, this.scene);
|
const tex = new Texture(texturePath, this.scene);
|
||||||
tex.updateSamplingMode(Texture.NEAREST_SAMPLINGMODE);
|
tex.updateSamplingMode(Texture.NEAREST_SAMPLINGMODE);
|
||||||
@ -439,7 +461,7 @@ export class BlockManager {
|
|||||||
* один meshes-prototype на тип блока, тысячи позиций в одном draw call.
|
* один meshes-prototype на тип блока, тысячи позиций в одном draw call.
|
||||||
* Жидкости (water/lava) идут по старому пути — у них свой single-surface.
|
* Жидкости (water/lava) идут по старому пути — у них свой single-surface.
|
||||||
*/
|
*/
|
||||||
addBlock(x, y, z, blockTypeId) {
|
addBlock(x, y, z, blockTypeId, color) {
|
||||||
const ix = Math.round(x);
|
const ix = Math.round(x);
|
||||||
const iy = Math.round(y);
|
const iy = Math.round(y);
|
||||||
const iz = Math.round(z);
|
const iz = Math.round(z);
|
||||||
@ -449,6 +471,9 @@ export class BlockManager {
|
|||||||
const typeDef = getBlockType(blockTypeId);
|
const typeDef = getBlockType(blockTypeId);
|
||||||
const isWater = !!typeDef?.isWater;
|
const isWater = !!typeDef?.isWater;
|
||||||
const isLava = !!typeDef?.isLava;
|
const isLava = !!typeDef?.isLava;
|
||||||
|
// Окрашиваемый блок: цвет инстанса (из аргумента или дефолт типа).
|
||||||
|
const colorable = !!typeDef?.colorable;
|
||||||
|
const instColor = colorable ? (color || typeDef.defaultColor || '#cccccc') : null;
|
||||||
|
|
||||||
// Для жидкостей оставляем старую логику: невидимый куб + единый surface
|
// Для жидкостей оставляем старую логику: невидимый куб + единый surface
|
||||||
if (isWater || isLava) {
|
if (isWater || isLava) {
|
||||||
@ -496,6 +521,9 @@ export class BlockManager {
|
|||||||
keysArr[idx] = key;
|
keysArr[idx] = key;
|
||||||
this._cellToInst.set(key, { typeId: blockTypeId, idx });
|
this._cellToInst.set(key, { typeId: blockTypeId, idx });
|
||||||
|
|
||||||
|
// Окрашиваемый блок — пишем цвет инстанса в color buffer.
|
||||||
|
if (colorable) this._setBlockColorAt(blockTypeId, idx, instColor);
|
||||||
|
|
||||||
// Логический «meshProxy» — объект, имитирующий API mesh для совместимости.
|
// Логический «meshProxy» — объект, имитирующий API mesh для совместимости.
|
||||||
// НЕ создаёт реального меша. Используется selection / removeBlockByMesh.
|
// НЕ создаёт реального меша. Используется selection / removeBlockByMesh.
|
||||||
const meshProxy = {
|
const meshProxy = {
|
||||||
@ -511,6 +539,7 @@ export class BlockManager {
|
|||||||
mass: 1,
|
mass: 1,
|
||||||
folderId: null,
|
folderId: null,
|
||||||
_thinIdx: idx,
|
_thinIdx: idx,
|
||||||
|
color: instColor, // per-instance цвет окрашиваемого блока
|
||||||
},
|
},
|
||||||
// Минимальные методы, которые ожидает остальной код
|
// Минимальные методы, которые ожидает остальной код
|
||||||
position: new Vector3(ix, iy + 0.5, iz),
|
position: new Vector3(ix, iy + 0.5, iz),
|
||||||
@ -538,6 +567,18 @@ export class BlockManager {
|
|||||||
proto.material = material;
|
proto.material = material;
|
||||||
if (isMulti) this._setupSubmeshes(proto);
|
if (isMulti) this._setupSubmeshes(proto);
|
||||||
|
|
||||||
|
// Окрашиваемый блок — включаем per-instance color buffer (vertex colors).
|
||||||
|
const _bt = getBlockType(blockTypeId);
|
||||||
|
if (_bt && _bt.colorable) {
|
||||||
|
proto.useVertexColors = true;
|
||||||
|
proto.hasVertexAlpha = false;
|
||||||
|
const buf = new Float32Array(this._STUDS_MAX * 4);
|
||||||
|
// Дефолт-цвет (белый) для всех слотов — иначе невыставленные = чёрные.
|
||||||
|
buf.fill(1);
|
||||||
|
proto.thinInstanceSetBuffer('color', buf, 4, false);
|
||||||
|
this._colorsByProto.set(blockTypeId, buf);
|
||||||
|
}
|
||||||
|
|
||||||
proto.checkCollisions = false; // коллизии через thin-instances не работают штатно;
|
proto.checkCollisions = false; // коллизии через thin-instances не работают штатно;
|
||||||
// PlayerController в Play-режиме читает blocks Map напрямую (см. PhysicsAABB).
|
// PlayerController в Play-режиме читает blocks Map напрямую (см. PhysicsAABB).
|
||||||
proto.isPickable = true;
|
proto.isPickable = true;
|
||||||
@ -569,6 +610,44 @@ export class BlockManager {
|
|||||||
/** Подписка для уведомлений о создании prototype-меша (для shadow setup). */
|
/** Подписка для уведомлений о создании prototype-меша (для shadow setup). */
|
||||||
setOnProtoCreated(cb) { this._onProtoCreated = cb; }
|
setOnProtoCreated(cb) { this._onProtoCreated = cb; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Записать цвет инстанса окрашиваемого блока в color buffer (RGBA float).
|
||||||
|
* idx — индекс thin-instance. hex — '#rrggbb'. В batch-режиме обновление
|
||||||
|
* GPU откладывается (флаг dirty), иначе сразу thinInstanceBufferUpdated.
|
||||||
|
*/
|
||||||
|
_setBlockColorAt(blockTypeId, idx, hex) {
|
||||||
|
const buf = this._colorsByProto.get(blockTypeId);
|
||||||
|
if (!buf) return;
|
||||||
|
const c = Color3.FromHexString(hex || '#cccccc');
|
||||||
|
const o = idx * 4;
|
||||||
|
buf[o] = c.r; buf[o + 1] = c.g; buf[o + 2] = c.b; buf[o + 3] = 1;
|
||||||
|
const proto = this._protoMeshes.get(blockTypeId);
|
||||||
|
if (!proto) return;
|
||||||
|
if (this._batchMode) {
|
||||||
|
if (!this._colorDirtyProtos) this._colorDirtyProtos = new Set();
|
||||||
|
this._colorDirtyProtos.add(blockTypeId);
|
||||||
|
} else {
|
||||||
|
try { proto.thinInstanceBufferUpdated('color'); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сменить цвет окрашиваемого блока в (x,y,z) на лету (для scene.setColor /
|
||||||
|
* color-пикера). Возвращает true если блок окрашиваемый и цвет применён.
|
||||||
|
*/
|
||||||
|
setBlockColor(x, y, z, hex) {
|
||||||
|
const key = this._key(Math.round(x), Math.round(y), Math.round(z));
|
||||||
|
const inst = this._cellToInst.get(key);
|
||||||
|
if (!inst) return false;
|
||||||
|
const bt = getBlockType(inst.typeId);
|
||||||
|
if (!bt || !bt.colorable) return false;
|
||||||
|
this._setBlockColorAt(inst.typeId, inst.idx, hex);
|
||||||
|
const mp = this.blocks.get(key);
|
||||||
|
if (mp && mp.metadata) mp.metadata.color = hex;
|
||||||
|
this._notifyChange();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Установить флаг anchored у блока. */
|
/** Установить флаг anchored у блока. */
|
||||||
setBlockAnchored(x, y, z, anchored) {
|
setBlockAnchored(x, y, z, anchored) {
|
||||||
const mesh = this.blocks.get(this._key(x, y, z));
|
const mesh = this.blocks.get(this._key(x, y, z));
|
||||||
@ -765,6 +844,8 @@ export class BlockManager {
|
|||||||
canCollide: m.canCollide !== false,
|
canCollide: m.canCollide !== false,
|
||||||
visible: m.visible !== false,
|
visible: m.visible !== false,
|
||||||
mass: m.mass ?? 1,
|
mass: m.mass ?? 1,
|
||||||
|
// per-instance цвет окрашиваемого блока (studs-block); иначе пропускаем
|
||||||
|
...(m.color ? { color: m.color } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@ -776,7 +857,7 @@ export class BlockManager {
|
|||||||
this._batchMode = true;
|
this._batchMode = true;
|
||||||
try {
|
try {
|
||||||
for (const b of arr) {
|
for (const b of arr) {
|
||||||
const mesh = this.addBlock(b.x, b.y, b.z, b.type);
|
const mesh = this.addBlock(b.x, b.y, b.z, b.type, b.color);
|
||||||
if (!mesh) continue;
|
if (!mesh) continue;
|
||||||
if (b.anchored === false) {
|
if (b.anchored === false) {
|
||||||
mesh.metadata.anchored = false;
|
mesh.metadata.anchored = false;
|
||||||
@ -800,6 +881,14 @@ export class BlockManager {
|
|||||||
proto.thinInstanceRefreshBoundingInfo(true);
|
proto.thinInstanceRefreshBoundingInfo(true);
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
// Финальный refresh color-буферов окрашиваемых блоков (batch).
|
||||||
|
if (this._colorDirtyProtos) {
|
||||||
|
for (const typeId of this._colorDirtyProtos) {
|
||||||
|
const proto = this._protoMeshes.get(typeId);
|
||||||
|
try { proto?.thinInstanceBufferUpdated('color'); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
this._colorDirtyProtos.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
|
|||||||
@ -105,6 +105,14 @@ export const BLOCK_TYPES = [
|
|||||||
// top = stone, side = oven (4 стороны с дверцей — лучше чем раньше),
|
// top = stone, side = oven (4 стороны с дверцей — лучше чем раньше),
|
||||||
// bottom = stone. В будущем для одной двери понадобится 6-face формат.
|
// bottom = stone. В будущем для одной двери понадобится 6-face формат.
|
||||||
multiCube('oven', 'Печь', 'Особые', `${TEX}/stone.png`, `${TEX}/oven.png`, `${TEX}/stone.png`),
|
multiCube('oven', 'Печь', 'Особые', `${TEX}/stone.png`, `${TEX}/oven.png`, `${TEX}/stone.png`),
|
||||||
|
|
||||||
|
// === ОКРАШИВАЕМЫЕ (задача 09) ===
|
||||||
|
// studs-block: лего-кирпич, цвет задаётся per-instance (vertex color на
|
||||||
|
// ThinInstance умножается на серую studs-текстуру). colorable:true говорит
|
||||||
|
// палитре показать color-пикер, BlockManager — включить color buffer.
|
||||||
|
cube('studs-block', 'Лего-кирпич', 'Окрашиваемые',
|
||||||
|
'/kubikon-assets/materials/studs_v4_diffuse.png',
|
||||||
|
{ colorable: true, normal: '/kubikon-assets/materials/studs_v4_normal.png', defaultColor: '#3a7aff' }),
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Все доступные категории в порядке появления. */
|
/** Все доступные категории в порядке появления. */
|
||||||
@ -121,6 +129,7 @@ export const CATEGORY_COLORS = {
|
|||||||
'Особые': '#9966ff',
|
'Особые': '#9966ff',
|
||||||
'Природа': '#5a8c3e',
|
'Природа': '#5a8c3e',
|
||||||
'Тест': '#3357FF',
|
'Тест': '#3357FF',
|
||||||
|
'Окрашиваемые': '#3a7aff',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -2571,6 +2571,16 @@ export class GameRuntime {
|
|||||||
try {
|
try {
|
||||||
const color = payload?.color;
|
const color = payload?.color;
|
||||||
if (typeof color !== 'string') return;
|
if (typeof color !== 'string') return;
|
||||||
|
// Окрашиваемый блок (studs-block): ref вида 'block:x,y,z' →
|
||||||
|
// меняем per-instance цвет через BlockManager.setBlockColor.
|
||||||
|
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)) {
|
||||||
|
this.scene3d?.blockManager?.setBlockColor?.(parts[0], parts[1], parts[2], color);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const pm = this.scene3d?.primitiveManager;
|
const pm = this.scene3d?.primitiveManager;
|
||||||
if (!pm) return;
|
if (!pm) return;
|
||||||
const rid = this._resolvePrimitiveId(payload?.id);
|
const rid = this._resolvePrimitiveId(payload?.id);
|
||||||
@ -2584,6 +2594,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) {
|
||||||
@ -3424,7 +3438,8 @@ export class GameRuntime {
|
|||||||
if (!this._localToReal) this._localToReal = new Map();
|
if (!this._localToReal) this._localToReal = new Map();
|
||||||
try {
|
try {
|
||||||
if (kind === 'block') {
|
if (kind === 'block') {
|
||||||
this.scene3d?.blockManager?.addBlock(payload.x, payload.y, payload.z, subType);
|
// color — для окрашиваемых блоков (studs-block); иначе игнорируется.
|
||||||
|
this.scene3d?.blockManager?.addBlock(payload.x, payload.y, payload.z, subType, payload.color);
|
||||||
// Для блоков ref детерминированный, но запоминаем — чтобы при
|
// Для блоков ref детерминированный, но запоминаем — чтобы при
|
||||||
// Stop удалить заспавненные скриптом блоки (см. stop()).
|
// Stop удалить заспавненные скриптом блоки (см. stop()).
|
||||||
if (ref) this._localToReal.set(ref, ref);
|
if (ref) this._localToReal.set(ref, ref);
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export class GizmoController {
|
|||||||
|
|
||||||
setOnDragEnd(cb) { this._onDragEnd = cb; }
|
setOnDragEnd(cb) { this._onDragEnd = cb; }
|
||||||
setOnDragStart(cb) { this._onDragStart = cb; }
|
setOnDragStart(cb) { this._onDragStart = cb; }
|
||||||
|
setOnDrag(cb) { this._onDrag = cb; }
|
||||||
|
|
||||||
/** Главный метод — переключить режим. */
|
/** Главный метод — переключить режим. */
|
||||||
setMode(mode) {
|
setMode(mode) {
|
||||||
@ -88,6 +89,13 @@ export class GizmoController {
|
|||||||
gizmo.onDragStartObservable.add(() => {
|
gizmo.onDragStartObservable.add(() => {
|
||||||
if (this._onDragStart) this._onDragStart();
|
if (this._onDragStart) this._onDragStart();
|
||||||
});
|
});
|
||||||
|
// onDrag (в процессе перетаскивания) — для live-обновления (напр. тайлинг
|
||||||
|
// studs, чтобы кружки не растягивались пока тянешь scale-гизмо).
|
||||||
|
if (gizmo.onDragObservable) {
|
||||||
|
gizmo.onDragObservable.add(() => {
|
||||||
|
if (this._onDrag) this._onDrag(mode);
|
||||||
|
});
|
||||||
|
}
|
||||||
gizmo.onDragEndObservable.add(() => {
|
gizmo.onDragEndObservable.add(() => {
|
||||||
if (this._onDragEnd) this._onDragEnd();
|
if (this._onDragEnd) this._onDragEnd();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -879,6 +879,97 @@ export const MODEL_TYPES = [
|
|||||||
m('pg-fence-planks', 'Забор', 'Площадка', 'nature-kit', 'fence_planks',
|
m('pg-fence-planks', 'Забор', 'Площадка', 'nature-kit', 'fence_planks',
|
||||||
{ targetHeight: 1.5 }),
|
{ targetHeight: 1.5 }),
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// === ЛЕГО-СЕТ (задача 09) — готовые модели из studs-примитивов ===
|
||||||
|
// Все части material:'studs' (лего-кружки). Цвета базовые из набора LEGO.
|
||||||
|
// ============================================================
|
||||||
|
// --- Кирпичи (стандартные плотности, высота 1) ---
|
||||||
|
mc('lego-brick-1x1', 'Лего 1×1', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 1, sy: 1, sz: 1, color: '#e02a2a', material: 'studs', dy: 0.5 },
|
||||||
|
], { category: 'Лего-сет' }),
|
||||||
|
mc('lego-brick-1x2', 'Лего 1×2', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1, sz: 1, color: '#2a6fe0', material: 'studs', dy: 0.5 },
|
||||||
|
]),
|
||||||
|
mc('lego-brick-1x4', 'Лего 1×4', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 4, sy: 1, sz: 1, color: '#f0c020', material: 'studs', dy: 0.5 },
|
||||||
|
]),
|
||||||
|
mc('lego-brick-2x2', 'Лего 2×2', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1, sz: 2, color: '#35ba5c', material: 'studs', dy: 0.5 },
|
||||||
|
]),
|
||||||
|
mc('lego-brick-2x4', 'Лего 2×4', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 4, sy: 1, sz: 2, color: '#e07a30', material: 'studs', dy: 0.5 },
|
||||||
|
]),
|
||||||
|
mc('lego-brick-2x8', 'Лего 2×8', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 8, sy: 1, sz: 2, color: '#9b5cf0', material: 'studs', dy: 0.5 },
|
||||||
|
]),
|
||||||
|
// --- Плиты (плоские, высота 0.35) ---
|
||||||
|
mc('lego-plate-1x1', 'Плита 1×1', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 1, sy: 0.35, sz: 1, color: '#cfd2d6', material: 'studs', dy: 0.175 },
|
||||||
|
]),
|
||||||
|
mc('lego-plate-1x2', 'Плита 1×2', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 2, sy: 0.35, sz: 1, color: '#cfd2d6', material: 'studs', dy: 0.175 },
|
||||||
|
]),
|
||||||
|
mc('lego-plate-2x2', 'Плита 2×2', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 2, sy: 0.35, sz: 2, color: '#cfd2d6', material: 'studs', dy: 0.175 },
|
||||||
|
]),
|
||||||
|
mc('lego-plate-4x4', 'Плита 4×4', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 4, sy: 0.35, sz: 4, color: '#9aa0a6', material: 'studs', dy: 0.175 },
|
||||||
|
]),
|
||||||
|
// --- Скаты (наклонные кирпичи через wedge) ---
|
||||||
|
mc('lego-slope-30', 'Скат 30°', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'wedge', sx: 2, sy: 1, sz: 2, color: '#e02a2a', material: 'studs', dy: 0.5 },
|
||||||
|
]),
|
||||||
|
mc('lego-slope-45', 'Скат 45°', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'wedge', sx: 2, sy: 2, sz: 2, color: '#2a6fe0', material: 'studs', dy: 1 },
|
||||||
|
]),
|
||||||
|
mc('lego-slope-60', 'Скат 60°', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'wedge', sx: 2, sy: 3, sz: 2, color: '#f0c020', material: 'studs', dy: 1.5 },
|
||||||
|
]),
|
||||||
|
// --- Лего-дерево (ствол коричневый + крона зелёная из кубов) ---
|
||||||
|
mc('lego-tree', 'Лего-дерево', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 1, sy: 3, sz: 1, color: '#8a5a2b', material: 'studs', dy: 1.5 },
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 3, sy: 2, sz: 3, color: '#35ba5c', material: 'studs', dy: 4 },
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1.5, sz: 2, color: '#2e9e4c', material: 'studs', dy: 5.5 },
|
||||||
|
], { targetHeight: 6 }),
|
||||||
|
// --- Лего-куст (компактный из мелких зелёных кубов) ---
|
||||||
|
mc('lego-bush', 'Лего-куст', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1, sz: 2, color: '#2e9e4c', material: 'studs', dy: 0.5 },
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 1, sy: 1, sz: 1, color: '#35ba5c', material: 'studs', dy: 1.3, dx: 0.4 },
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 1, sy: 1, sz: 1, color: '#35ba5c', material: 'studs', dy: 1.3, dx: -0.5, dz: 0.3 },
|
||||||
|
], { targetHeight: 1.8 }),
|
||||||
|
// --- Лего-дом (стены красные, крыша синяя, дверь жёлтая) ---
|
||||||
|
mc('lego-house-small', 'Лего-дом', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 6, sy: 4, sz: 6, color: '#e02a2a', material: 'studs', dy: 2 },
|
||||||
|
{ kind: 'primitive', type: 'wedge', sx: 7, sy: 2.5, sz: 7, color: '#2a6fe0', material: 'studs', dy: 5.25 },
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 1.4, sy: 2.4, sz: 0.3, color: '#f0c020', material: 'studs', dy: 1.2, dz: -3.05 },
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 1.2, sy: 1.2, sz: 0.3, color: '#9ad0ff', material: 'studs', dy: 2.6, dz: -3.05, dx: 1.8 },
|
||||||
|
], { targetHeight: 6.5 }),
|
||||||
|
// --- Лего-машина-гонщик (каркас + кабина + 4 колеса) ---
|
||||||
|
mc('lego-car-racer', 'Лего-машина', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 6, sy: 1, sz: 3, color: '#e02a2a', material: 'studs', dy: 0.9 },
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 2.5, sy: 1.2, sz: 2.6, color: '#2a6fe0', material: 'studs', dy: 1.9, dx: 0.6 },
|
||||||
|
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: 1.8, dz: 1.6, rz: Math.PI / 2 },
|
||||||
|
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: 1.8, dz: -1.6, rz: Math.PI / 2 },
|
||||||
|
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: -1.8, dz: 1.6, rz: Math.PI / 2 },
|
||||||
|
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: -1.8, dz: -1.6, rz: Math.PI / 2 },
|
||||||
|
], { targetHeight: 2.5 }),
|
||||||
|
// --- Лего-ступеньки (4 ступени разной высоты) ---
|
||||||
|
mc('lego-stairs', 'Лего-ступеньки', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 3, sy: 1, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 0.5, dz: 1.8 },
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 3, sy: 2, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 1.0, dz: 0.6 },
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 3, sy: 3, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 1.5, dz: -0.6 },
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 3, sy: 4, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 2.0, dz: -1.8 },
|
||||||
|
], { targetHeight: 4 }),
|
||||||
|
// --- Лего-человечек (минифигурка для NPC) ---
|
||||||
|
mc('lego-minifig', 'Лего-человечек', 'Лего-сет', [
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 1.4, sy: 0.6, sz: 0.9, color: '#f0c020', material: 'studs', dy: 0.3 },
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 1.2, sy: 1.6, sz: 0.8, color: '#2a6fe0', material: 'studs', dy: 1.4 },
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 0.4, sy: 1.4, sz: 0.4, color: '#f0c020', material: 'studs', dy: 1.4, dx: 0.85 },
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 0.4, sy: 1.4, sz: 0.4, color: '#f0c020', material: 'studs', dy: 1.4, dx: -0.85 },
|
||||||
|
{ kind: 'primitive', type: 'cube', sx: 1.1, sy: 1.0, sz: 0.85, color: '#f5c84a', material: 'studs', dy: 2.7 },
|
||||||
|
{ kind: 'primitive', type: 'cylinder', sx: 0.9, sy: 0.5, sz: 0.9, color: '#e02a2a', material: 'studs', dy: 3.4 },
|
||||||
|
], { targetHeight: 3.8 }),
|
||||||
|
|
||||||
// TOTAL: 644
|
// TOTAL: 644
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -19,11 +19,85 @@
|
|||||||
* При касании игроком обновляет spawnPoint сцены.
|
* При касании игроком обновляет spawnPoint сцены.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
MeshBuilder, StandardMaterial, Color3, Vector3, PointLight,
|
MeshBuilder, StandardMaterial, Color3, Vector3, Vector4, PointLight,
|
||||||
Mesh, VertexData, Texture, DynamicTexture,
|
Mesh, VertexData, Texture, DynamicTexture,
|
||||||
} from '@babylonjs/core';
|
} from '@babylonjs/core';
|
||||||
import { getPrimitiveType } from './PrimitiveTypes';
|
import { getPrimitiveType } from './PrimitiveTypes';
|
||||||
|
|
||||||
|
// === Материал «studs» (лего-кружки, задача 09) ===
|
||||||
|
// Серая diffuse-текстура с сеткой выпуклых кружков (multiply на цвет меша) +
|
||||||
|
// normal map для иллюзии выпуклости. 1 stud = STUD_UNIT юнитов → тайлинг
|
||||||
|
// считается из реального размера меша (куб 4×4 покажет 4×4 кружка).
|
||||||
|
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 }>. Каждый меш получает свою
|
||||||
|
// материал-копию (свой цвет/тайлинг), но текстуры шарятся.
|
||||||
|
const _studsTexCache = new WeakMap();
|
||||||
|
function _getStudsTextures(scene) {
|
||||||
|
let c = _studsTexCache.get(scene);
|
||||||
|
if (!c) {
|
||||||
|
const diffuse = new Texture(STUDS_DIFFUSE_URL, scene);
|
||||||
|
const normal = new Texture(STUDS_NORMAL_URL, scene);
|
||||||
|
c = { diffuse, normal };
|
||||||
|
_studsTexCache.set(scene, c);
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Посчитать тайлинг (uScale/vScale) для studs по размеру меша. Чтобы кружки не
|
||||||
|
* растягивались: число кружков на грань = размер_грани / STUD_UNIT, делённое на
|
||||||
|
* число кружков в самой текстуре (STUDS_GRID).
|
||||||
|
* Для куба/плоскости тайлинг прямой; для сферы/цилиндра — приближённый.
|
||||||
|
*/
|
||||||
|
function _studsTiling(type, sx, sy, sz, density) {
|
||||||
|
// density — множитель плотности кружков (1=стандарт, 2=вдвое мельче/чаще).
|
||||||
|
const d = density && density > 0 ? density : 1;
|
||||||
|
const f = (STUD_UNIT * STUDS_GRID) / d;
|
||||||
|
// По умолчанию (cube/plane/wedge) — горизонтальный размер по U, вертикаль по V.
|
||||||
|
let u = Math.max(sx, sz) / f;
|
||||||
|
let v = sy / f;
|
||||||
|
if (type === 'cylinder') {
|
||||||
|
// боковая поверхность — по обхвату; торцы по диаметру
|
||||||
|
u = (Math.PI * sx) / f;
|
||||||
|
v = sy / f;
|
||||||
|
} else if (type === 'sphere') {
|
||||||
|
u = (Math.PI * sx) / f;
|
||||||
|
v = (Math.PI * sy) / f;
|
||||||
|
} else if (type === 'plane') {
|
||||||
|
u = sx / f;
|
||||||
|
v = sz / f;
|
||||||
|
}
|
||||||
|
return { u: Math.max(0.25, u), v: Math.max(0.25, v) };
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* faceUV для куба со studs — КАЖДАЯ грань тайлится по СВОИМ реальным размерам,
|
||||||
|
* чтобы кружки были одного размера на всех гранях (не растягивались на длинных).
|
||||||
|
* Грани CreateBox: 0=front(z-) 1=back(z+) 2=right(x+) 3=left(x-) 4=top(y+) 5=bottom(y-).
|
||||||
|
* front/back → ширина=sx, высота=sy
|
||||||
|
* left/right → ширина=sz, высота=sy
|
||||||
|
* top/bottom → ширина=sx, высота=sz
|
||||||
|
* UV-диапазон грани = (0,0)..(кол-во_studs_по_ширине, кол-во_по_высоте).
|
||||||
|
*/
|
||||||
|
function _studsCubeFaceUV(sx, sy, sz, density) {
|
||||||
|
const d = density && density > 0 ? density : 1;
|
||||||
|
const f = (STUD_UNIT * STUDS_GRID) / d;
|
||||||
|
const nx = Math.max(0.25, sx / f); // studs вдоль X
|
||||||
|
const ny = Math.max(0.25, sy / f); // studs вдоль Y
|
||||||
|
const nz = Math.max(0.25, sz / f); // studs вдоль Z
|
||||||
|
// Vector4(u0, v0, u1, v1)
|
||||||
|
return [
|
||||||
|
new Vector4(0, 0, nx, ny), // front (z-): X×Y
|
||||||
|
new Vector4(0, 0, nx, ny), // back (z+): X×Y
|
||||||
|
new Vector4(0, 0, nz, ny), // right (x+): Z×Y
|
||||||
|
new Vector4(0, 0, nz, ny), // left (x-): Z×Y
|
||||||
|
new Vector4(0, 0, nx, nz), // top (y+): X×Z
|
||||||
|
new Vector4(0, 0, nx, nz), // bottom (y-): X×Z
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export class PrimitiveManager {
|
export class PrimitiveManager {
|
||||||
constructor(scene) {
|
constructor(scene) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
@ -64,6 +138,8 @@ export class PrimitiveManager {
|
|||||||
const isGlowingGd = isGdKind;
|
const isGlowingGd = isGdKind;
|
||||||
const isGdSpike = typeDef.kind === 'gd_spike';
|
const isGdSpike = typeDef.kind === 'gd_spike';
|
||||||
const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
|
const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
|
||||||
|
// studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще).
|
||||||
|
const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1;
|
||||||
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции)
|
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции)
|
||||||
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike;
|
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike;
|
||||||
const visible = opts.visible !== false;
|
const visible = opts.visible !== false;
|
||||||
@ -81,7 +157,7 @@ export class PrimitiveManager {
|
|||||||
const rotationY = opts.rotationY ?? 0;
|
const rotationY = opts.rotationY ?? 0;
|
||||||
const rotationZ = opts.rotationZ ?? 0;
|
const rotationZ = opts.rotationZ ?? 0;
|
||||||
|
|
||||||
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz);
|
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity);
|
||||||
mesh.position = new Vector3(x, y, z);
|
mesh.position = new Vector3(x, y, z);
|
||||||
mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
|
mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
|
||||||
mesh.isPickable = true;
|
mesh.isPickable = true;
|
||||||
@ -103,13 +179,15 @@ export class PrimitiveManager {
|
|||||||
id, mesh, type, x, y, z, sx, sy, sz,
|
id, mesh, type, x, y, z, sx, sy, sz,
|
||||||
rotationX, rotationY, rotationZ,
|
rotationX, rotationY, rotationZ,
|
||||||
color, material, canCollide, visible, anchored, mass,
|
color, material, canCollide, visible, anchored, mass,
|
||||||
textureAsset,
|
textureAsset, studDensity,
|
||||||
// locked — объект защищён от выделения/перемещения в редакторе
|
// locked — объект защищён от выделения/перемещения в редакторе
|
||||||
// (Фаза 5.11). На геймплей не влияет.
|
// (Фаза 5.11). На геймплей не влияет.
|
||||||
locked: opts.locked === true,
|
locked: opts.locked === true,
|
||||||
name: opts.name || null,
|
name: opts.name || null,
|
||||||
folderId: opts.folderId ?? null,
|
folderId: opts.folderId ?? null,
|
||||||
};
|
};
|
||||||
|
// Размеры для тайлинга studs-материала (читается в _applyMaterial).
|
||||||
|
mesh._studsDims = { type, sx, sy, sz, density: studDensity };
|
||||||
this._applyMaterial(mesh, typeDef, color, material);
|
this._applyMaterial(mesh, typeDef, color, material);
|
||||||
this._applyVisible(mesh, visible, typeDef);
|
this._applyVisible(mesh, visible, typeDef);
|
||||||
// Пользовательская текстура — поверх базового материала.
|
// Пользовательская текстура — поверх базового материала.
|
||||||
@ -187,13 +265,17 @@ export class PrimitiveManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Создать базовый mesh нужной формы (без материала). */
|
/** Создать базовый mesh нужной формы (без материала). */
|
||||||
_createMeshForType(typeDef, id, sx, sy, sz) {
|
_createMeshForType(typeDef, id, sx, sy, sz, material, studDensity) {
|
||||||
const name = `prim_${typeDef.id}_${id}`;
|
const name = `prim_${typeDef.id}_${id}`;
|
||||||
switch (typeDef.id) {
|
switch (typeDef.id) {
|
||||||
case 'cube':
|
case 'cube':
|
||||||
case 'trigger':
|
case 'trigger': {
|
||||||
return MeshBuilder.CreateBox(name,
|
const boxOpts = { width: sx, height: sy, depth: sz };
|
||||||
{ width: sx, height: sy, depth: sz }, this.scene);
|
// studs — per-face UV, чтобы кружки были одного размера на всех
|
||||||
|
// гранях (не растягивались на длинной стороне).
|
||||||
|
if (material === 'studs') boxOpts.faceUV = _studsCubeFaceUV(sx, sy, sz, studDensity);
|
||||||
|
return MeshBuilder.CreateBox(name, boxOpts, this.scene);
|
||||||
|
}
|
||||||
case 'sphere':
|
case 'sphere':
|
||||||
return MeshBuilder.CreateSphere(name,
|
return MeshBuilder.CreateSphere(name,
|
||||||
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene);
|
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene);
|
||||||
@ -433,6 +515,34 @@ export class PrimitiveManager {
|
|||||||
mat.emissiveColor = Color3.FromHexString(color || '#888888');
|
mat.emissiveColor = Color3.FromHexString(color || '#888888');
|
||||||
mat.specularColor = new Color3(0, 0, 0);
|
mat.specularColor = new Color3(0, 0, 0);
|
||||||
break;
|
break;
|
||||||
|
case 'studs': {
|
||||||
|
// Лего-материал: почти белая diffuse-текстура с лёгкими кружками
|
||||||
|
// умножается на цвет меша → цвет остаётся СОЧНЫМ (как в Roblox).
|
||||||
|
// emissive = доля цвета → цвет «светится», не тускнеет в тени.
|
||||||
|
const tex = _getStudsTextures(this.scene);
|
||||||
|
const dt = tex.diffuse.clone();
|
||||||
|
const nt = tex.normal.clone();
|
||||||
|
const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
|
||||||
|
// Куб/триггер тайлятся через faceUV геометрии (uScale=1) — кружки
|
||||||
|
// одного размера на всех гранях. Остальные формы — через uScale.
|
||||||
|
if (dims.type === 'cube' || dims.type === 'trigger') {
|
||||||
|
dt.uScale = nt.uScale = 1;
|
||||||
|
dt.vScale = nt.vScale = 1;
|
||||||
|
} else {
|
||||||
|
const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz, dims.density);
|
||||||
|
dt.uScale = nt.uScale = tile.u;
|
||||||
|
dt.vScale = nt.vScale = tile.v;
|
||||||
|
}
|
||||||
|
mat.diffuseTexture = dt;
|
||||||
|
mat.bumpTexture = nt;
|
||||||
|
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':
|
case 'matte':
|
||||||
default:
|
default:
|
||||||
mat.specularColor = new Color3(0, 0, 0);
|
mat.specularColor = new Color3(0, 0, 0);
|
||||||
@ -576,6 +686,12 @@ export class PrimitiveManager {
|
|||||||
if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; }
|
if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; }
|
||||||
if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; }
|
if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; }
|
||||||
if (patch.sz !== undefined) { data.sz = patch.sz; scaleChanged = true; }
|
if (patch.sz !== undefined) { data.sz = patch.sz; scaleChanged = true; }
|
||||||
|
// Плотность studs (мелкие/крупные кружки) — требует пересоздания меша
|
||||||
|
// (faceUV для куба зашит в геометрию).
|
||||||
|
if (patch.studDensity !== undefined) {
|
||||||
|
data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1;
|
||||||
|
scaleChanged = true;
|
||||||
|
}
|
||||||
if (scaleChanged) {
|
if (scaleChanged) {
|
||||||
// Поскольку MeshBuilder уже создал mesh с базовыми размерами,
|
// Поскольку MeshBuilder уже создал mesh с базовыми размерами,
|
||||||
// изменения через scaling кажутся правильными. Простой способ —
|
// изменения через scaling кажутся правильными. Простой способ —
|
||||||
@ -601,6 +717,7 @@ export class PrimitiveManager {
|
|||||||
if (data.mesh.material) {
|
if (data.mesh.material) {
|
||||||
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
|
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
data.mesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz };
|
||||||
this._applyMaterial(data.mesh, typeDef, data.color, data.material);
|
this._applyMaterial(data.mesh, typeDef, data.color, data.material);
|
||||||
}
|
}
|
||||||
// Текстуру переприменяем если: сменили саму текстуру, или
|
// Текстуру переприменяем если: сменили саму текстуру, или
|
||||||
@ -614,6 +731,7 @@ export class PrimitiveManager {
|
|||||||
if (data.mesh.material) {
|
if (data.mesh.material) {
|
||||||
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
|
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
data.mesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz };
|
||||||
this._applyMaterial(data.mesh, typeDef, data.color, data.material);
|
this._applyMaterial(data.mesh, typeDef, data.color, data.material);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -722,10 +840,18 @@ export class PrimitiveManager {
|
|||||||
const oldMat = oldMesh.material;
|
const oldMat = oldMesh.material;
|
||||||
|
|
||||||
const typeDef = getPrimitiveType(data.type);
|
const typeDef = getPrimitiveType(data.type);
|
||||||
const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz);
|
const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity);
|
||||||
newMesh.position = oldPos;
|
newMesh.position = oldPos;
|
||||||
if (oldRot) newMesh.rotation = oldRot;
|
if (oldRot) newMesh.rotation = oldRot;
|
||||||
|
// studs — материал пересоздаём заново (свежий faceUV/тайлинг + текстура
|
||||||
|
// могла быть временно спрятана во время scale-drag). Иначе переносим старый.
|
||||||
|
if (data.material === 'studs') {
|
||||||
|
newMesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz, density: data.studDensity };
|
||||||
|
this._applyMaterial(newMesh, typeDef, data.color, data.material);
|
||||||
|
try { oldMat?.dispose(); } catch (e) { /* ignore */ }
|
||||||
|
} else {
|
||||||
newMesh.material = oldMat;
|
newMesh.material = oldMat;
|
||||||
|
}
|
||||||
newMesh.isPickable = true;
|
newMesh.isPickable = true;
|
||||||
newMesh.metadata = { ...oldMesh.metadata };
|
newMesh.metadata = { ...oldMesh.metadata };
|
||||||
newMesh.setEnabled(data.visible);
|
newMesh.setEnabled(data.visible);
|
||||||
@ -735,6 +861,8 @@ export class PrimitiveManager {
|
|||||||
catch (e) { /* ignore */ }
|
catch (e) { /* ignore */ }
|
||||||
|
|
||||||
data.mesh = newMesh;
|
data.mesh = newMesh;
|
||||||
|
// _studsDims и материал studs уже выставлены выше (через _applyMaterial
|
||||||
|
// на новом меше с правильным faceUV/тайлингом). Для не-studs ничего не надо.
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Удалить инстанс. */
|
/** Удалить инстанс. */
|
||||||
@ -787,6 +915,8 @@ export class PrimitiveManager {
|
|||||||
...(d.locked ? { locked: true } : {}),
|
...(d.locked ? { locked: true } : {}),
|
||||||
// id пользовательской текстуры (картинка из AssetManager).
|
// id пользовательской текстуры (картинка из AssetManager).
|
||||||
...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
|
...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
|
||||||
|
// Плотность studs (если не 1) — мелкие/крупные кружки.
|
||||||
|
...(d.studDensity && d.studDensity !== 1 ? { studDensity: d.studDensity } : {}),
|
||||||
// Параметры лампы (только для type='light', иначе undefined)
|
// Параметры лампы (только для type='light', иначе undefined)
|
||||||
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
|
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
|
||||||
// Параметр эмиттера (только для type='emitter')
|
// Параметр эмиттера (только для type='emitter')
|
||||||
|
|||||||
@ -1495,7 +1495,8 @@ const game = {
|
|||||||
if (kind === 'block') {
|
if (kind === 'block') {
|
||||||
const ix = Math.round(x), iy = Math.round(y), iz = Math.round(z);
|
const ix = Math.round(x), iy = Math.round(y), iz = Math.round(z);
|
||||||
const ref = 'block:' + ix + ',' + iy + ',' + iz;
|
const ref = 'block:' + ix + ',' + iy + ',' + iz;
|
||||||
_send('scene.spawn', { kind: 'block', subType, x: ix, y: iy, z: iz, ref });
|
// color — для окрашиваемых блоков (studs-block, задача 09).
|
||||||
|
_send('scene.spawn', { kind: 'block', subType, x: ix, y: iy, z: iz, ref, color: opts.color });
|
||||||
if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime);
|
if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime);
|
||||||
// 6.2: возвращаем Instance вместо строки (он coerces в строку через toString).
|
// 6.2: возвращаем Instance вместо строки (он coerces в строку через toString).
|
||||||
return _getOrCreateInstance(ref, 'block') || ref;
|
return _getOrCreateInstance(ref, 'block') || ref;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user