diff --git a/src/community/docsGames.js b/src/community/docsGames.js
index 3c2b525..d16b363 100644
--- a/src/community/docsGames.js
+++ b/src/community/docsGames.js
@@ -323,4 +323,9 @@ export const GAMES = [
desc: '3D-стрелка-указатель «иди сюда»: дорожка из бегущих шевронов + парящий маркер над целью. Дошёл — стрелка прыгает на следующую.',
mechanics: ['game.fx.pointer', 'preset стрелки', 'setTarget/update', 'onTouch цели'],
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 },
];
diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx
index a6f657a..07d451f 100644
--- a/src/community/docsLessons.jsx
+++ b/src/community/docsLessons.jsx
@@ -7900,6 +7900,88 @@ game.after(0.4, () => {
),
},
+ 'guide-lego': {
+ body: (
+ <>
+
Что получится
+
+ Лего-полигон в стиле Roblox: зелёный пол и оранжевая
+ стена из окрашиваемых блоков, жёлтая ступенька, синий
+ куб и фиолетовые кубики — все с узнаваемой текстурой
+ «studs» (лего-кружки). В углу — готовый лего-сет:
+ деревья, дом и машина. Один и тот же материал, любой цвет — как
+ настоящий конструктор.
+
+
+
+
+ Чему научишься
+
+ - material: 'studs' — лего-текстура на любом примитиве
+ (куб, сфера, цилиндр), цвет берётся из обычного color;
+ - studs-block — окрашиваемый блок: один тип, цвет
+ задаётся на каждый блок (per-instance), тысячи блоков — один
+ draw call;
+ - тайлинг по размеру — кружки не растягиваются: куб 4×4
+ покажет 4×4 studs, куб 1×1 — один;
+ - лего-сет — готовые модели (кирпичи, плиты, скаты,
+ дерево, дом, машина, человечек) из studs-примитивов.
+
+
+ Шаг 1. Пол и стена из studs-блоков
+
+
+ В палитре блоков открой категорию «Окрашиваемые» — там
+ блок Лего-кирпич (studs-block). Под
+ палитрой появится выбор цвета.
+
+
+ Поставь зелёным цветом большой пол (30×30), затем оранжевым —
+ стену вдоль дальнего края. Цвет каждого блока сохраняется
+ отдельно — стена и пол из одного типа блока.
+
+
+ Шаг 2. Studs на примитивах
+
+ Поставь куб (Примитив → cube). В инспекторе справа выбери
+ материал «Studs» (пятый рядом с Матовый/Металл/Стекло/Неон)
+ и задай цвет. Готово — лего-кружки на всех гранях.
+
+
+ Размер кружков считается автоматически от размера меша: растяни
+ куб — studs останутся того же масштаба, просто их станет больше.
+
+
+ Шаг 3. Через код
+
+ {`// Окрашиваемый блок — пол:
+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');`}
+
+
+ Собери из лего-кирпичей разных размеров (1×2, 2×4, 2×8) свою
+ постройку, покрась каждый ряд в свой цвет — и поставь сверху
+ готовую лего-машину из набора.
+
+ >
+ ),
+ },
+
};
/** Есть ли готовый текст урока для игры с таким id. */
diff --git a/src/editor/InspectorPanel.jsx b/src/editor/InspectorPanel.jsx
index 6c91732..a602e8a 100644
--- a/src/editor/InspectorPanel.jsx
+++ b/src/editor/InspectorPanel.jsx
@@ -267,6 +267,7 @@ const PRIMITIVE_MATERIALS = [
{ id: 'metal', name: 'Металл' },
{ id: 'glass', name: 'Стекло' },
{ id: 'neon', name: 'Неон' },
+ { id: 'studs', name: 'Studs' }, // задача 09 — лего-кружки (любой цвет)
];
/** Форматирование массы — без хвоста из десятичных. Целые без запятой. */
diff --git a/src/editor/engine/BlockManager.js b/src/editor/engine/BlockManager.js
index de4dcc1..0a3ed3b 100644
--- a/src/editor/engine/BlockManager.js
+++ b/src/editor/engine/BlockManager.js
@@ -94,6 +94,10 @@ export class BlockManager {
this._lavaSurfaceBaseY = null;
this._lavaDirty = false;
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,22 @@ export class BlockManager {
const mat = new StandardMaterial(name, this.scene);
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) {}
+ }
+ mat.specularColor = new Color3(0.2, 0.2, 0.2);
+ mat.specularPower = 24;
+ mat.useVertexColors = true;
+ return mat;
+ }
+
if (texturePath) {
const tex = new Texture(texturePath, this.scene);
tex.updateSamplingMode(Texture.NEAREST_SAMPLINGMODE);
@@ -439,7 +459,7 @@ export class BlockManager {
* один meshes-prototype на тип блока, тысячи позиций в одном draw call.
* Жидкости (water/lava) идут по старому пути — у них свой single-surface.
*/
- addBlock(x, y, z, blockTypeId) {
+ addBlock(x, y, z, blockTypeId, color) {
const ix = Math.round(x);
const iy = Math.round(y);
const iz = Math.round(z);
@@ -449,6 +469,9 @@ export class BlockManager {
const typeDef = getBlockType(blockTypeId);
const isWater = !!typeDef?.isWater;
const isLava = !!typeDef?.isLava;
+ // Окрашиваемый блок: цвет инстанса (из аргумента или дефолт типа).
+ const colorable = !!typeDef?.colorable;
+ const instColor = colorable ? (color || typeDef.defaultColor || '#cccccc') : null;
// Для жидкостей оставляем старую логику: невидимый куб + единый surface
if (isWater || isLava) {
@@ -496,6 +519,9 @@ export class BlockManager {
keysArr[idx] = key;
this._cellToInst.set(key, { typeId: blockTypeId, idx });
+ // Окрашиваемый блок — пишем цвет инстанса в color buffer.
+ if (colorable) this._setBlockColorAt(blockTypeId, idx, instColor);
+
// Логический «meshProxy» — объект, имитирующий API mesh для совместимости.
// НЕ создаёт реального меша. Используется selection / removeBlockByMesh.
const meshProxy = {
@@ -511,6 +537,7 @@ export class BlockManager {
mass: 1,
folderId: null,
_thinIdx: idx,
+ color: instColor, // per-instance цвет окрашиваемого блока
},
// Минимальные методы, которые ожидает остальной код
position: new Vector3(ix, iy + 0.5, iz),
@@ -538,6 +565,18 @@ export class BlockManager {
proto.material = material;
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 не работают штатно;
// PlayerController в Play-режиме читает blocks Map напрямую (см. PhysicsAABB).
proto.isPickable = true;
@@ -569,6 +608,44 @@ export class BlockManager {
/** Подписка для уведомлений о создании prototype-меша (для shadow setup). */
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 у блока. */
setBlockAnchored(x, y, z, anchored) {
const mesh = this.blocks.get(this._key(x, y, z));
@@ -765,6 +842,8 @@ export class BlockManager {
canCollide: m.canCollide !== false,
visible: m.visible !== false,
mass: m.mass ?? 1,
+ // per-instance цвет окрашиваемого блока (studs-block); иначе пропускаем
+ ...(m.color ? { color: m.color } : {}),
});
}
return out;
@@ -776,7 +855,7 @@ export class BlockManager {
this._batchMode = true;
try {
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 (b.anchored === false) {
mesh.metadata.anchored = false;
@@ -800,6 +879,14 @@ export class BlockManager {
proto.thinInstanceRefreshBoundingInfo(true);
} 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() {
diff --git a/src/editor/engine/BlockTypes.js b/src/editor/engine/BlockTypes.js
index b8da9ac..c58181f 100644
--- a/src/editor/engine/BlockTypes.js
+++ b/src/editor/engine/BlockTypes.js
@@ -105,6 +105,14 @@ export const BLOCK_TYPES = [
// top = stone, side = oven (4 стороны с дверцей — лучше чем раньше),
// bottom = stone. В будущем для одной двери понадобится 6-face формат.
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_diffuse.png',
+ { colorable: true, normal: '/kubikon-assets/materials/studs_normal.png', defaultColor: '#3a7aff' }),
];
/** Все доступные категории в порядке появления. */
@@ -121,6 +129,7 @@ export const CATEGORY_COLORS = {
'Особые': '#9966ff',
'Природа': '#5a8c3e',
'Тест': '#3357FF',
+ 'Окрашиваемые': '#3a7aff',
};
/**
diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js
index 2aaae16..de0c3f9 100644
--- a/src/editor/engine/GameRuntime.js
+++ b/src/editor/engine/GameRuntime.js
@@ -2550,6 +2550,16 @@ export class GameRuntime {
try {
const color = payload?.color;
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;
if (!pm) return;
const rid = this._resolvePrimitiveId(payload?.id);
@@ -3383,7 +3393,8 @@ export class GameRuntime {
if (!this._localToReal) this._localToReal = new Map();
try {
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 детерминированный, но запоминаем — чтобы при
// Stop удалить заспавненные скриптом блоки (см. stop()).
if (ref) this._localToReal.set(ref, ref);
diff --git a/src/editor/engine/ModelTypes.js b/src/editor/engine/ModelTypes.js
index 5badaab..cf2a7d5 100644
--- a/src/editor/engine/ModelTypes.js
+++ b/src/editor/engine/ModelTypes.js
@@ -879,6 +879,97 @@ export const MODEL_TYPES = [
m('pg-fence-planks', 'Забор', 'Площадка', 'nature-kit', 'fence_planks',
{ 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
];
diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js
index a9f0c3b..4f64fec 100644
--- a/src/editor/engine/PrimitiveManager.js
+++ b/src/editor/engine/PrimitiveManager.js
@@ -24,6 +24,53 @@ import {
} from '@babylonjs/core';
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_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
+// Кэш текстур на сцену: чтобы не грузить один и тот же PNG для каждого меша.
+// Map. Каждый меш получает свою
+// материал-копию (свой цвет/тайлинг), но текстуры шарятся.
+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) {
+ const f = STUD_UNIT * STUDS_GRID;
+ // По умолчанию (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) };
+}
+
export class PrimitiveManager {
constructor(scene) {
this.scene = scene;
@@ -110,6 +157,8 @@ export class PrimitiveManager {
name: opts.name || null,
folderId: opts.folderId ?? null,
};
+ // Размеры для тайлинга studs-материала (читается в _applyMaterial).
+ mesh._studsDims = { type, sx, sy, sz };
this._applyMaterial(mesh, typeDef, color, material);
this._applyVisible(mesh, visible, typeDef);
// Пользовательская текстура — поверх базового материала.
@@ -433,6 +482,26 @@ export class PrimitiveManager {
mat.emissiveColor = Color3.FromHexString(color || '#888888');
mat.specularColor = new Color3(0, 0, 0);
break;
+ case 'studs': {
+ // Лего-материал: серая diffuse-текстура с кружками умножается на
+ // цвет меша (StandardMaterial.diffuseTexture * diffuseColor), normal
+ // map даёт выпуклость. Тайлинг — по реальному размеру меша.
+ 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 };
+ const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz);
+ dt.uScale = nt.uScale = tile.u;
+ 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;
+ break;
+ }
case 'matte':
default:
mat.specularColor = new Color3(0, 0, 0);
@@ -601,6 +670,7 @@ export class PrimitiveManager {
if (data.mesh.material) {
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);
}
// Текстуру переприменяем если: сменили саму текстуру, или
@@ -614,6 +684,7 @@ export class PrimitiveManager {
if (data.mesh.material) {
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);
}
@@ -735,6 +806,17 @@ export class PrimitiveManager {
catch (e) { /* ignore */ }
data.mesh = newMesh;
+ newMesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz };
+ // studs-материал: пересчитать тайлинг под новый размер меша.
+ if (data.material === 'studs' && oldMat && oldMat.diffuseTexture) {
+ const tile = _studsTiling(data.type, data.sx, data.sy, data.sz);
+ oldMat.diffuseTexture.uScale = tile.u;
+ oldMat.diffuseTexture.vScale = tile.v;
+ if (oldMat.bumpTexture) {
+ oldMat.bumpTexture.uScale = tile.u;
+ oldMat.bumpTexture.vScale = tile.v;
+ }
+ }
}
/** Удалить инстанс. */
diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js
index eb4469f..e282138 100644
--- a/src/editor/engine/ScriptSandboxWorker.js
+++ b/src/editor/engine/ScriptSandboxWorker.js
@@ -1466,7 +1466,8 @@ const game = {
if (kind === 'block') {
const ix = Math.round(x), iy = Math.round(y), iz = Math.round(z);
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);
// 6.2: возвращаем Instance вместо строки (он coerces в строку через toString).
return _getOrCreateInstance(ref, 'block') || ref;